letmeknow-cli 0.6.1 → 0.8.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 +24 -69
  2. package/SKILL.md +156 -39
  3. package/bin/letmeknow.js +271 -199
  4. package/package.json +4 -3
package/README.md CHANGED
@@ -1,98 +1,53 @@
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 for structured human feedback. The agent serves ordinary HTML and static assets, receives browser submissions, and can push ordered HTML replacements.
4
4
 
5
- ## Start a session
5
+ ## Quick start
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 with an `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
+ 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.
20
20
 
21
- ## Publish revisions
21
+ ## Commands
22
22
 
23
- `serve` snapshots the initial folder. Later filesystem changes remain private until explicitly published:
24
-
25
- ```bash
26
- npx letmeknow-cli pull ./preview --wait 30
27
- npx letmeknow-cli push ./preview --based-on <batch-token>
28
- ```
29
-
30
- `pull` returns pending browser interactions, the current workspace, and an opaque batch token:
31
-
32
- ```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
- }
23
+ ```text
24
+ serve <dir>
25
+ show <dir>
26
+ pull <dir> [--wait seconds]
27
+ push <dir> --batch TOKEN [--updates FILE|-]
51
28
  ```
52
29
 
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.
54
-
55
- If a batch requires no workspace change, commit it without publishing:
56
-
57
- ```bash
58
- npx letmeknow-cli ack ./preview --based-on <batch-token>
59
- ```
30
+ `serve` reads `index.html` once as the canonical page. Other files are served live from the directory. `show` prints the accepted canonical HTML. `pull` returns browser events without consuming them; `push` commits a pulled batch, optionally with ordered replacements. See [`SKILL.md`](SKILL.md) for the complete workflow and protocol.
60
31
 
61
- `push` and `ack` are idempotent for a token. They fail if another command has moved the workspace or event cursor first.
32
+ ## Page updates
62
33
 
63
- The commands communicate with `serve` through a private local Unix socket. `--skill` prints agent instructions without starting a session.
64
-
65
- ## Workspace behavior
66
-
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.
68
-
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.
70
-
71
- ## Forms
72
-
73
- Use native same-origin GET or POST forms:
34
+ Use stable IDs as replacement boundaries:
74
35
 
75
36
  ```html
76
- <form id="decision" action="/decide" method="post">
77
- <label>Comment <textarea id="comment" name="comment"></textarea></label>
78
- <button name="decision" value="approve">Approve</button>
79
- <button name="decision" value="reject">Reject</button>
80
- </form>
37
+ <output id="counter">41</output>
81
38
  ```
82
39
 
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.
84
-
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.
86
-
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.
40
+ ```json
41
+ {"updates":[{"target":"counter","html":"<output id=\"counter\">42</output>"}]}
42
+ ```
88
43
 
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.
44
+ Each replacement must contain one element with the target's ID and cannot contain scripts. Replacements in one push are applied in order and atomically. Replacing `letmeknow-root` is the broad page-update case and intentionally discards browser-local state inside that root.
90
45
 
91
- ## Security
46
+ ## Session and security basics
92
47
 
93
- The URL is a bearer capability. The relay receives published files and submitted values. Keep secrets and unrelated files outside the preview directory.
48
+ Sessions are temporary and held in memory by the CLI and relay. The service may expire a session at any time. If the producer disconnects, reconnect is best-effort and may be available only for a limited period; clients should handle disconnects without assuming that reconnect will succeed.
94
49
 
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.
50
+ The public URL is a bearer capability: anyone who has it can view the page and submit forms. Treat browser values as untrusted input and escape them before placing them in HTML. Keep secrets and unrelated files outside the served directory.
96
51
 
97
52
  ## Development
98
53
 
@@ -104,4 +59,4 @@ npm run dev
104
59
  npm run deploy
105
60
  ```
106
61
 
107
- The browser test requires Firefox and geckodriver. `npm run dev` and `npm run deploy` operate the Cloudflare relay.
62
+ The browser smoke test requires Firefox and geckodriver. `npm run dev` and `npm run deploy` operate the Cloudflare relay. For the detailed agent workflow, forms, event causality, reconnect behavior, and static-asset guidance, read [`SKILL.md`](SKILL.md).
package/SKILL.md CHANGED
@@ -1,89 +1,206 @@
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 ordered HTML replacements.
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`, then run:
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 initial canonical dynamic page and serves it at `/`. Other files—such as CSS, JavaScript, images, and data—are served live from the directory. The first stdout JSON line contains the public bearer URL and initial page metadata:
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
+ The initial page should have a stable shell and a dynamic root:
25
+
26
+ ```html
27
+ <body>
28
+ <main id="letmeknow-root">
29
+ ...
30
+ </main>
31
+ <script type="module" src="/app.js"></script>
32
+ </body>
33
+ ```
34
+
35
+ The runtime is injected into the page separately. Keep agent-authored scripts and the runtime outside normal replacement targets.
36
+
37
+ ## Session lifetime and reconnect
38
+
39
+ A producer must successfully send `open` shortly after connecting. Sessions are temporary and may be expired by the service at any time.
40
+
41
+ If an opened producer disconnects, reconnect is best-effort and may be available only for a limited period. A reconnect uses the existing session credential, but clients must handle disconnects without assuming that reconnect will succeed. When the service expires a session, the session and its browser connections end.
25
42
 
26
43
  ## Agent loop
27
44
 
28
- Filesystem writes are private drafts. Explicitly pull feedback and publish coherent revisions:
45
+ Pull browser events, update one or more HTML fragments, and push the replacements:
29
46
 
30
47
  ```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>
48
+ batch=$(npx letmeknow-cli pull ./preview --wait 30)
49
+ token=$(printf '%s\n' "$batch" | jq -r .token)
50
+ # inspect events and write fragments such as counter.html and status.html
51
+ npx letmeknow-cli push ./preview --batch "$token" --updates updates.json
34
52
  ```
35
53
 
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.
54
+ Commands:
37
55
 
38
- When a batch needs no visible workspace change:
56
+ ```text
57
+ serve <dir>
58
+ show <dir>
59
+ pull <dir> [--wait seconds]
60
+ push <dir> --batch TOKEN [--updates FILE|-]
61
+ ```
62
+
63
+ `show` returns the current canonical page without changing the event stream:
39
64
 
40
65
  ```bash
41
- npx letmeknow-cli ack ./preview --based-on <batch-token>
66
+ npx letmeknow-cli show ./preview > current.html
42
67
  ```
43
68
 
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.
69
+ The public URL is the visual preview. `show` returns canonical HTML, not a browser's local focus, open disclosures, unsent input, scroll position, or JavaScript state.
45
70
 
46
- A push may publish independent work from an empty batch. Use the token returned by an empty `pull`.
71
+ ## Push updates
47
72
 
48
- ## Build the workspace
73
+ A push accepts one JSON document:
49
74
 
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.
75
+ ```json
76
+ {
77
+ "updates": [
78
+ {"target": "counter", "file": "counter.html"},
79
+ {"target": "status", "html": "<output id=\"status\">Saved</output>"}
80
+ ]
81
+ }
82
+ ```
51
83
 
52
- Use native same-origin GET or POST forms:
84
+ Each update must contain exactly one of `html` or `file`. A file supplies the replacement HTML. The normal operation is direct replacement of one element identified by its unique stable `id`:
53
85
 
54
86
  ```html
55
- <form id="review" action="/review" method="post">
56
- <label>Comment <textarea id="comment" name="comment"></textarea></label>
57
- <button name="decision" value="approve">Approve</button>
58
- <button name="decision" value="reject">Reject</button>
59
- </form>
87
+ <output id="counter">41</output>
88
+ ```
89
+
90
+ ```json
91
+ {"updates":[{"target":"counter","html":"<output id=\"counter\">42</output>"}]}
92
+ ```
93
+
94
+ The replacement must contain exactly one element, and that element must have the same ID as the target. Replacement HTML cannot contain scripts. The target must exist when the replacement is applied.
95
+
96
+ Use small output regions for ordinary updates. The replacement is destructive inside its target but leaves the rest of the page alone, so a counter update does not disturb a form or button elsewhere.
97
+
98
+ The document root is just another target. To intentionally replace all dynamic content, target the root:
99
+
100
+ ```html
101
+ <main id="letmeknow-root">
102
+ ...
103
+ </main>
104
+ ```
105
+
106
+ ```json
107
+ {"updates":[{"target":"letmeknow-root","file":"root.html"}]}
108
+ ```
109
+
110
+ Replacing the root intentionally discards browser-local state inside it. Use it when that is acceptable, not for every small change.
111
+
112
+ A single push may contain many replacements. They are applied in the order listed. Later replacements may target elements introduced by earlier replacements in the same push, so introduce a target before updating it. Conversely, a replacement that removes a later target makes a following update invalid. The CLI validates the complete ordered batch before committing anything.
113
+
114
+ Each replacement becomes its own `update_ui` event with its own global event number. The complete push is still atomic: either all replacements and the pulled browser-event batch commit, or none do. Connected browsers receive committed replacements in order.
115
+
116
+ A push without `--updates` commits the pulled browser events without changing the page:
117
+
118
+ ```bash
119
+ npx letmeknow-cli push ./preview --batch "$token"
60
120
  ```
61
121
 
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.
122
+ There is no separate acknowledgement command and no `--page` mode. Replacing `letmeknow-root` provides the broad page-update case.
63
123
 
64
- A pulled event includes the workspace the human saw:
124
+ ## Event stream and page causality
125
+
126
+ Browser submissions and CLI page replacements share one ordered, in-memory event stream:
127
+
128
+ ```text
129
+ submit browser
130
+ submit browser
131
+ update_ui CLI: replace #counter
132
+ update_ui CLI: replace #status
133
+ ```
134
+
135
+ The CLI assigns event numbers in acceptance order. They do not claim to be the physical order in which people clicked. Browser submissions are delivered to the agent through `pull`; raw submissions are not broadcast to other browsers. Page replacements are broadcast to all connected browsers.
136
+
137
+ `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.
65
138
 
66
139
  ```json
67
140
  {
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
- }
141
+ "token": "",
142
+ "frontier": 7,
143
+ "page_event": 5,
144
+ "page_hash": "",
145
+ "events": [
146
+ {
147
+ "type": "submit",
148
+ "id": "…",
149
+ "event_number": 7,
150
+ "page_event": 5,
151
+ "form_id": "decision",
152
+ "action": "/decide",
153
+ "trigger": {"name": "decision", "value": "approve"},
154
+ "values": {"comment": "Looks good", "decision": "approve"}
155
+ }
156
+ ]
78
157
  }
79
158
  ```
80
159
 
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.
160
+ `page_event` is the page-update event number displayed when the browser submitted. Compare each event's `page_event` with the batch's current page before applying old input to the current HTML. `frontier` is the latest global event number, including events that are not browser submissions.
161
+
162
+ The token identifies exactly the browser-event frontier the agent saw. A successful push commits that frontier and its ordered replacements together. Browser events accepted after the pull remain for the next batch.
163
+
164
+ ## Forms
165
+
166
+ Use ordinary same-origin forms with stable IDs and meaningful field names:
167
+
168
+ ```html
169
+ <form id="decision" action="/decide" method="post">
170
+ <label>Comment <textarea id="comment" name="comment"></textarea></label>
171
+ <button name="decision" value="approve">Approve</button>
172
+ <button name="decision" value="reject">Reject</button>
173
+ </form>
174
+ ```
175
+
176
+ The runtime converts native form submission into a JSON `submit` event. It assigns an opaque UUID, stores each event in a durable browser outbox before delivery, retries after connection failures, and reuses the UUID on retry. The CLI deduplicates repeated delivery of the same event. Distinct intentional submissions remain distinct, including rapid repeated clicks. File uploads are not supported.
177
+
178
+ Treat pulled values as untrusted input. Validate them and escape them before putting them into HTML.
179
+
180
+ ## Browser and HTML rules
181
+
182
+ Normal updates are direct replacements, not DOM morphs. Stable IDs are therefore important for naming update boundaries, not for preserving DOM nodes.
183
+
184
+ - The CLI owns content inside replacement targets.
185
+ - The browser owns local focus, open/closed disclosure state, and hide/show behavior outside deliberately replaced targets.
186
+ - A root replacement can destroy all local state inside the root.
187
+ - Load agent-authored JavaScript from the initial page as a static asset.
188
+ - Use delegated event listeners because replaced elements are new DOM nodes.
189
+ - Scripts in update fragments are not executed.
190
+ - Do not have browser JavaScript and pushed HTML independently own the same state.
191
+
192
+ When a session ends, the browser shows a permanent closed status and discards unsent submissions rather than retrying them.
193
+
194
+ ## Static assets
195
+
196
+ 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 page update.
197
+
198
+ This design intentionally does not provide atomic publication of the whole directory. The dynamic page changes through ordered replacements; other files can change as soon as they are written.
82
199
 
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.
200
+ ## Security
84
201
 
85
- Escape untrusted text before placing it in HTML.
202
+ 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.
86
203
 
87
204
  ## Stop
88
205
 
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.
206
+ Send `SIGINT` or `SIGTERM` to `serve`. The temporary session ends when the process stops.
package/bin/letmeknow.js CHANGED
@@ -1,25 +1,31 @@
1
1
  #!/usr/bin/env node
2
2
 
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";
3
+ import { constants, createReadStream, existsSync, readFileSync, statSync, writeSync } from "node:fs";
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
+ import { parse, parseFragment, serialize } from "parse5";
10
11
  import { lookup } from "mrmime";
11
12
 
12
13
  const MAX_BODY_BYTES = 1024 * 1024;
13
- const GRACE_SECONDS = 10 * 60;
14
+ const UPDATE_BATCH_MAX_BYTES = 16 * MAX_BODY_BYTES;
15
+ const MAX_BATCH_TOKENS = 100_000;
16
+ const MAX_UNIQUE_SUBMISSIONS = 100_000;
17
+ const MAX_RETAINED_SUBMISSION_BYTES = 256 * 1024 * 1024;
18
+ const CONTROL_MAX_BYTES = UPDATE_BATCH_MAX_BYTES * 2;
19
+ const RECONNECT_RETRY_SECONDS = 10 * 60;
14
20
  const CONNECTION_TIMEOUT = 10_000;
15
21
  const CONTROL_TIMEOUT = 35_000;
16
- const MAX_RETRY_DELAY = 5_000;
17
22
  const CONTROL_PREFIX = "letmeknow-control-";
18
- const SNAPSHOT_PREFIX = "letmeknow-snapshot-";
19
23
  const CONTROL_URL = "https://letmeknow.dev";
20
24
  const credentialPattern = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
21
25
  const privateNames = new Set([".env", ".git", ".ssh", "id_rsa", "id_ed25519", "id_ecdsa", "id_dsa"]);
22
26
  const privateFilePattern = /^\.env\.|\.(?:key|pem|p12|ppk|p8|sqlite|sqlite3|db|db3)$|-(?:wal|shm|journal)$/i;
27
+ const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
28
+ const forbiddenUpdateTargets = new Set(["html", "head", "body", "script"]);
23
29
 
24
30
  function getMimeType(filename) {
25
31
  const type = lookup(filename);
@@ -34,17 +40,6 @@ function header(packet, name) {
34
40
  return typeof entry?.[1] === "string" && entry[1] !== "" ? entry[1] : null;
35
41
  }
36
42
 
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
43
  function response(packet, status, body = Buffer.alloc(0), headers = {}) {
49
44
  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
45
  const outputHeaders = { "Cache-Control": "no-store", ...headers };
@@ -93,12 +88,15 @@ function requestUrl(packet) {
93
88
  return { pathname, encodedPathname: url.pathname, search: url.search };
94
89
  }
95
90
 
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 });
91
+ async function staticResponse(root, packet, page, pageEvent) {
92
+ const published = (status, body = Buffer.alloc(0), headers = {}) => response(packet, status, body, headers);
98
93
  const method = typeof packet.method === "string" ? packet.method.toUpperCase() : "";
99
94
  if (method !== "GET" && method !== "HEAD") return errorResponse(packet, 405, "method not allowed");
100
95
  let request;
101
96
  try { request = requestUrl(packet); } catch { return errorResponse(packet, 400, "bad request"); }
97
+ if (request.pathname === "/" || request.pathname === "/index.html") {
98
+ return published(200, Buffer.from(page), { "Content-Type": "text/html; charset=utf-8", "X-LetMeKnow-Page-Event": String(pageEvent) });
99
+ }
102
100
  if (deniedPath(request.pathname)) return errorResponse(packet, 403, "forbidden");
103
101
  const candidate = resolve(root, "." + request.pathname);
104
102
  if (!inside(root, candidate)) return errorResponse(packet, 403, "forbidden");
@@ -151,69 +149,157 @@ async function staticResponse(root, packet, workspaceId) {
151
149
  }
152
150
  }
153
151
 
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;
152
+ async function readInitialPage(root) {
153
+ const candidate = join(root, "index.html");
154
+ const target = await safeRealpath(root, candidate);
155
+ if (target === null || target === undefined || deniedPath("/" + relative(root, target).split(sep).join("/"))) throw new Error("index.html is required");
156
+ const info = await stat(target);
157
+ if (!info.isFile()) throw new Error("index.html must be a file");
158
+ if (info.size > MAX_BODY_BYTES) throw new Error("index.html is too large");
159
+ return (await readFile(target)).toString("utf8");
160
+ }
161
+
162
+ function nodeId(node) {
163
+ return node.attrs?.find(attribute => attribute.name === "id")?.value;
164
+ }
165
+
166
+ function elementsWithId(node, id, matches = []) {
167
+ if (node.nodeName !== "#text" && node.nodeName !== "#comment" && nodeId(node) === id) matches.push(node);
168
+ for (const child of node.childNodes || []) elementsWithId(child, id, matches);
169
+ return matches;
170
+ }
171
+
172
+ function containsScript(node) {
173
+ if (node.nodeName === "script") return true;
174
+ if (node.content && containsScript(node.content)) return true;
175
+ return (node.childNodes || []).some(containsScript);
176
+ }
177
+
178
+ function updateFragment(target, html, context) {
179
+ const fragment = parseFragment(context, html);
180
+ const meaningful = (fragment.childNodes || []).filter(node => node.nodeName !== "#text" || node.value.trim() !== "");
181
+ if (meaningful.length !== 1 || meaningful[0].nodeName === "#comment" || meaningful[0].nodeName?.startsWith("#")) throw new Error(`update for ${target} must contain exactly one root element`);
182
+ const root = meaningful[0];
183
+ if (containsScript(root)) throw new Error(`update for ${target} cannot contain script elements`);
184
+ if (nodeId(root) !== target) throw new Error(`update root ID must be ${target}`);
185
+ return root;
186
+ }
187
+
188
+ function replaceElement(document, target, html) {
189
+ if (forbiddenUpdateTargets.has(target.nodeName)) throw new Error(`cannot update ${target.nodeName} element`);
190
+ const parent = target.parentNode;
191
+ const index = parent?.childNodes.indexOf(target);
192
+ if (!parent || index === undefined || index < 0) throw new Error("update target has no parent");
193
+ const replacement = updateFragment(nodeId(target), html, parent);
194
+ parent.childNodes[index] = replacement;
195
+ replacement.parentNode = parent;
196
+ target.parentNode = null;
197
+ }
198
+
199
+ function validateUpdates(updates) {
200
+ if (!Array.isArray(updates)) throw new Error("updates must be an array");
201
+ let bytes = 0;
202
+ for (const update of updates) {
203
+ if (!update || typeof update !== "object" || Array.isArray(update)) throw new Error("each update must be an object");
204
+ if (typeof update.target !== "string" || update.target.trim() === "") throw new Error("update target must be a non-empty ID");
205
+ if (typeof update.html !== "string") throw new Error("update html must be text");
206
+ bytes += Buffer.byteLength(update.html, "utf8");
207
+ if (bytes > UPDATE_BATCH_MAX_BYTES) throw new Error("updates are too large");
208
+ }
209
+ return updates;
210
+ }
211
+
212
+ function applyUpdates(page, updates) {
213
+ validateUpdates(updates);
214
+ const document = parse(page);
215
+ for (const update of updates) {
216
+ const matches = elementsWithId(document, update.target);
217
+ if (matches.length !== 1) throw new Error(`update target ${update.target} must match exactly one element`);
218
+ replaceElement(document, matches[0], update.html);
219
+ }
220
+ const result = serialize(document);
221
+ if (Buffer.byteLength(result, "utf8") > MAX_BODY_BYTES) throw new Error("updated page is too large");
222
+ return result;
223
+ }
224
+
225
+ async function readInput(filename) {
226
+ const chunks = [];
227
+ let length = 0;
228
+ const input = filename === "-" ? process.stdin : createReadStream(filename);
229
+ for await (const chunk of input) {
230
+ const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
231
+ length += value.byteLength;
232
+ if (length > UPDATE_BATCH_MAX_BYTES) throw new Error("updates are too large");
233
+ chunks.push(value);
234
+ }
235
+ return Buffer.concat(chunks).toString("utf8");
236
+ }
237
+
238
+ async function readUpdatesInput(filename) {
239
+ const manifest = await readInput(filename);
240
+ let value;
241
+ try { value = JSON.parse(manifest); } catch { throw new Error("invalid updates JSON"); }
242
+ if (!value || typeof value !== "object" || Array.isArray(value) || !Array.isArray(value.updates)) throw new Error("updates must be an object with an updates array");
243
+ const base = filename === "-" ? process.cwd() : dirname(resolve(filename));
244
+ const updates = [];
245
+ let bytes = 0;
246
+ for (const item of value.updates) {
247
+ if (!item || typeof item !== "object" || Array.isArray(item)) throw new Error("each update must be an object");
248
+ if (typeof item.target !== "string" || item.target.trim() === "") throw new Error("update target must be a non-empty ID");
249
+ const hasHtml = Object.prototype.hasOwnProperty.call(item, "html");
250
+ const hasFile = Object.prototype.hasOwnProperty.call(item, "file");
251
+ if (hasHtml === hasFile) throw new Error(`update for ${item.target} must have exactly one of html or file`);
252
+ let html;
253
+ if (hasHtml) {
254
+ if (typeof item.html !== "string") throw new Error(`update html for ${item.target} must be text`);
255
+ html = item.html;
256
+ } else {
257
+ if (typeof item.file !== "string" || item.file === "") throw new Error(`update file for ${item.target} must be a path`);
258
+ const file = resolve(base, item.file);
259
+ const info = await stat(file);
260
+ if (!info.isFile()) throw new Error(`update file for ${item.target} must be a regular file`);
261
+ if (info.size > UPDATE_BATCH_MAX_BYTES - bytes) throw new Error("updates are too large");
262
+ html = (await readFile(file)).toString("utf8");
166
263
  }
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 });
264
+ bytes += Buffer.byteLength(html, "utf8");
265
+ if (bytes > UPDATE_BATCH_MAX_BYTES) throw new Error("updates are too large");
266
+ updates.push({ target: item.target, html });
172
267
  }
173
- return { values, attachments };
268
+ return updates;
269
+ }
270
+
271
+ function updatesHash(updates) {
272
+ return createHash("sha256").update(JSON.stringify(updates)).digest("hex");
174
273
  }
175
274
 
176
- async function submission(packet, getAttachmentInbox, recordInteraction) {
177
- const url = requestUrl(packet);
275
+ async function submission(packet, recordInteraction) {
276
+ const request = requestUrl(packet);
178
277
  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);
278
+ if (method !== "POST" || request.pathname !== "/_letmeknow/submit") throw new Error("invalid submission endpoint");
279
+ const contentType = header(packet, "content-type")?.split(";", 1)[0].trim().toLowerCase();
280
+ if (contentType !== "application/json") throw new Error("JSON submission is required");
281
+ const body = Buffer.from(typeof packet.body === "string" ? packet.body : "", "base64");
282
+ if (body.byteLength > MAX_BODY_BYTES) throw new Error("submission is too large");
283
+ let value;
284
+ try { value = JSON.parse(body.toString("utf8")); } catch { throw new Error("invalid submission JSON"); }
285
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("submission must be an object");
286
+ if (typeof value.id !== "string" || !uuidPattern.test(value.id)) throw new Error("submission id must be a UUID");
287
+ if (!Number.isSafeInteger(value.page_event) || value.page_event < 0) throw new Error("page_event must be a non-negative integer");
288
+ if (value.form_id !== null && typeof value.form_id !== "string") throw new Error("form_id must be text or null");
289
+ if (typeof value.action !== "string") throw new Error("action is required");
290
+ if (value.trigger !== null && (typeof value.trigger !== "object" || Array.isArray(value.trigger))) throw new Error("trigger must be an object or null");
291
+ if (!value.values || typeof value.values !== "object" || Array.isArray(value.values)) throw new Error("values are required");
292
+ 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 }, body.byteLength);
209
293
  return response(packet, 202);
210
294
  }
211
295
 
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"); }
296
+ async function handleRequest(root, page, pageEvent, packet, recordInteraction) {
297
+ let request;
298
+ try { request = requestUrl(packet); } catch { return errorResponse(packet, 400, "bad request"); }
299
+ if (request.pathname === "/_letmeknow/submit") {
300
+ 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
301
  }
216
- return staticResponse(root, packet, workspaceId);
302
+ return staticResponse(root, packet, page, pageEvent);
217
303
  }
218
304
 
219
305
  function options(directory) {
@@ -227,41 +313,6 @@ function controlPath(root) {
227
313
  return join(tmpdir(), `${CONTROL_PREFIX}${key}.sock`);
228
314
  }
229
315
 
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
- visited.delete(sourceReal);
252
- }
253
-
254
- async function snapshotDirectory(root) {
255
- const snapshot = await mkdtemp(join(tmpdir(), SNAPSHOT_PREFIX));
256
- try {
257
- await copyDirectory(root, snapshot, root);
258
- return snapshot;
259
- } catch (cause) {
260
- await rm(snapshot, { recursive: true, force: true });
261
- throw cause;
262
- }
263
- }
264
-
265
316
  function mutateQueue() {
266
317
  let chain = Promise.resolve();
267
318
  return operation => {
@@ -303,20 +354,20 @@ function connectControl(root, packet) {
303
354
  });
304
355
  }
305
356
 
357
+ function pageHash(page) {
358
+ return createHash("sha256").update(page).digest("hex");
359
+ }
360
+
306
361
  async function start(directory) {
307
362
  const { root } = await options(directory);
308
- let attachmentInboxPromise;
309
- const getAttachmentInbox = () => {
310
- attachmentInboxPromise ??= mkdtemp(join(tmpdir(), "letmeknow-attachments-"));
311
- return attachmentInboxPromise;
312
- };
313
- const socketPath = controlPath(root);
314
- let publishedRoot = await snapshotDirectory(root);
315
- let workspaceId = randomUUID();
316
- let workspaceSequence = 1;
317
- const workspaceIds = new Set([workspaceId]);
318
- const eventLog = [];
319
- let committedCursor = 0;
363
+ let page = await readInitialPage(root);
364
+ let pageEvent = 0;
365
+ let eventNumber = 0;
366
+ const currentPageHash = () => pageHash(page);
367
+ const browserEvents = [];
368
+ const browserEventBytes = [];
369
+ let retainedSubmissionBytes = 0;
370
+ let committedBrowserCursor = 0;
320
371
  const seenEvents = new Set();
321
372
  const tokens = new Map();
322
373
  const pendingTokens = new Map();
@@ -333,32 +384,28 @@ async function start(directory) {
333
384
  let retryUntil = 0;
334
385
  let stopped = false;
335
386
  let ready = false;
336
- let initialPublished = false;
387
+ let stopSession = () => {};
337
388
 
338
389
  const batch = () => {
339
- const start = committedCursor;
340
- const end = eventLog.length;
341
- const key = `${workspaceId}:${start}:${end}`;
342
- const existing = pendingTokens.get(key);
343
- if (existing) return existing;
344
- const token = randomUUID();
345
- const events = eventLog.slice(start, end).map(event => ({
346
- ...event,
347
- context: {
348
- based_on: event.based_on ?? null,
349
- current: workspaceId,
350
- relationship: event.based_on === workspaceId ? "current" : workspaceIds.has(event.based_on) ? "stale" : "unknown"
390
+ const start = committedBrowserCursor;
391
+ const end = start + browserEvents.length;
392
+ const key = `${pageEvent}:${start}:${end}`;
393
+ let token = pendingTokens.get(key)?.token;
394
+ if (!token) {
395
+ if (tokens.size >= MAX_BATCH_TOKENS) {
396
+ stopSession();
397
+ throw new Error("session batch token limit exceeded");
351
398
  }
352
- }));
353
- tokens.set(token, { start, end, parent: workspaceId, status: "pending", key });
354
- const result = { ok: true, type: "batch", token, workspace: workspaceId, workspace_sequence: workspaceSequence, frontier: end, events };
355
- pendingTokens.set(key, result);
356
- return result;
399
+ token = randomUUID();
400
+ tokens.set(token, { start, end, page_event: pageEvent, status: "pending", key, updates_hash: null });
401
+ pendingTokens.set(key, { token });
402
+ }
403
+ return { ok: true, type: "batch", token, frontier: eventNumber, page_event: pageEvent, page_hash: currentPageHash(), events: browserEvents.slice() };
357
404
  };
358
405
 
359
406
  const notifyPullWaiters = () => {
360
407
  for (const waiter of [...pullWaiters]) {
361
- if (eventLog.length === committedCursor) continue;
408
+ if (browserEvents.length === 0) continue;
362
409
  pullWaiters.delete(waiter);
363
410
  clearTimeout(waiter.timer);
364
411
  waiter.resolve(batch());
@@ -366,53 +413,71 @@ async function start(directory) {
366
413
  };
367
414
 
368
415
  const pull = waitSeconds => {
369
- if (eventLog.length > committedCursor || waitSeconds <= 0) return Promise.resolve(batch());
416
+ if (browserEvents.length > 0 || waitSeconds <= 0) return Promise.resolve(batch());
370
417
  return new Promise(resolve => {
371
418
  const waiter = { resolve, timer: setTimeout(() => { pullWaiters.delete(waiter); resolve(batch()); }, waitSeconds * 1_000) };
372
419
  pullWaiters.add(waiter);
373
420
  });
374
421
  };
375
422
 
376
- const commit = async (token, publish) => {
423
+ const commit = async (token, requestedUpdates) => {
377
424
  const record = tokens.get(token);
378
425
  if (!record) return { ok: false, error: "unknown batch token" };
379
- if (record.status !== "pending") return record.result;
380
- if (record.start < committedCursor && record.end <= committedCursor) {
381
- pendingTokens.delete(record.key);
382
- record.status = "committed";
383
- record.result = { ok: true, type: "already_committed", token, workspace: workspaceId, workspace_sequence: workspaceSequence, frontier: committedCursor };
384
- return record.result;
426
+ const updates = validateUpdates(requestedUpdates);
427
+ const requestedUpdatesHash = updatesHash(updates);
428
+ if (record.status !== "pending") {
429
+ if (record.updates_hash === requestedUpdatesHash) return record.result;
430
+ return { ok: false, error: "batch was already committed with a different updates payload" };
385
431
  }
386
- if (record.parent !== workspaceId || record.start !== committedCursor) {
432
+ if (record.page_event !== pageEvent || record.start !== committedBrowserCursor) {
433
+ record.updates_hash = requestedUpdatesHash;
387
434
  pendingTokens.delete(record.key);
388
435
  record.status = "failed";
389
- record.result = { ok: false, error: "batch is based on an old workspace or cursor", current_workspace: workspaceId, frontier: committedCursor };
436
+ record.result = { ok: false, error: "batch is based on an old page or browser cursor", frontier: eventNumber, page_event: pageEvent, page_hash: currentPageHash() };
390
437
  return record.result;
391
438
  }
392
- if (publish) {
393
- const nextRoot = await snapshotDirectory(root);
394
- const previousRoot = publishedRoot;
395
- publishedRoot = nextRoot;
396
- workspaceId = randomUUID();
397
- workspaceIds.add(workspaceId);
398
- workspaceSequence += 1;
399
- 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) };
400
- void rm(previousRoot, { recursive: true, force: true }).catch(() => {});
401
- } else {
402
- 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) };
439
+ let nextPage = page;
440
+ try { if (updates.length) nextPage = applyUpdates(page, updates); } catch (cause) {
441
+ throw new Error(cause instanceof Error ? cause.message : "invalid page updates");
442
+ }
443
+ const committedCount = record.end - record.start;
444
+ const committedEvents = browserEvents.slice(0, committedCount).map(event => event.id);
445
+ retainedSubmissionBytes -= browserEventBytes.slice(0, committedCount).reduce((total, bytes) => total + bytes, 0);
446
+ const updateEvents = [];
447
+ for (const update of updates) {
448
+ eventNumber += 1;
449
+ updateEvents.push({ type: "update_ui", event_number: eventNumber, target: update.target, html: update.html });
403
450
  }
404
- committedCursor = record.end;
451
+ page = nextPage;
452
+ pageEvent = updateEvents.at(-1)?.event_number ?? pageEvent;
453
+ browserEvents.splice(0, committedCount);
454
+ browserEventBytes.splice(0, committedCount);
455
+ committedBrowserCursor = record.end;
405
456
  pendingTokens.delete(record.key);
406
457
  record.status = "committed";
407
- if (publish) send({ type: "revision" });
458
+ record.updates_hash = requestedUpdatesHash;
459
+ record.result = {
460
+ ok: true,
461
+ type: "committed",
462
+ token,
463
+ frontier: eventNumber,
464
+ page_event: pageEvent,
465
+ page_hash: currentPageHash(),
466
+ events: committedEvents,
467
+ updates: updateEvents.map(({ event_number, target }) => ({ event_number, target }))
468
+ };
469
+ for (const update of updateEvents) send(update);
408
470
  return record.result;
409
471
  };
410
472
 
411
473
  const dispatchControl = async request => {
412
474
  if (!request || typeof request !== "object") return { ok: false, error: "invalid control request" };
413
475
  if (request.type === "pull") return pull(Number.isFinite(request.wait_seconds) ? Math.max(0, request.wait_seconds) : 0);
414
- if (request.type === "push") return commit(typeof request.token === "string" ? request.token : "", true);
415
- if (request.type === "ack") return commit(typeof request.token === "string" ? request.token : "", false);
476
+ if (request.type === "show") return { ok: true, type: "page", page_event: pageEvent, page_hash: currentPageHash(), html: page };
477
+ if (request.type === "push") {
478
+ if (typeof request.token !== "string") return { ok: false, error: "batch token is required" };
479
+ return commit(request.token, request.updates === undefined ? [] : request.updates);
480
+ }
416
481
  return { ok: false, error: "unknown control request" };
417
482
  };
418
483
 
@@ -424,7 +489,7 @@ async function start(directory) {
424
489
  let handled = false;
425
490
  connection.on("data", async chunk => {
426
491
  input += chunk;
427
- if (input.length > MAX_BODY_BYTES || handled) return;
492
+ if (Buffer.byteLength(input, "utf8") > CONTROL_MAX_BYTES || handled) return;
428
493
  const newline = input.indexOf("\n");
429
494
  if (newline < 0) return;
430
495
  handled = true;
@@ -440,20 +505,25 @@ async function start(directory) {
440
505
  });
441
506
  await new Promise((resolveListen, reject) => {
442
507
  controlServer.once("error", reject);
443
- controlServer.listen(socketPath, async () => {
444
- try { await chmod(socketPath, 0o600); } catch (cause) { controlServer.close(() => reject(cause)); return; }
508
+ controlServer.listen(controlPath(root), async () => {
509
+ try { await chmod(controlPath(root), 0o600); } catch (cause) { controlServer.close(() => reject(cause)); return; }
445
510
  controlServer.off("error", reject);
446
511
  resolveListen();
447
512
  });
448
- }).catch(async cause => {
449
- await rm(publishedRoot, { recursive: true, force: true });
450
- throw new Error(`cannot start local control channel: ${cause.message}`);
451
- });
513
+ }).catch(cause => { throw new Error(`cannot start local control channel: ${cause.message}`); });
452
514
 
453
- const recordInteraction = event => mutate(async () => {
515
+ const recordInteraction = (event, bytes) => mutate(async () => {
454
516
  if (seenEvents.has(event.id)) return;
517
+ if (seenEvents.size >= MAX_UNIQUE_SUBMISSIONS || retainedSubmissionBytes + bytes > MAX_RETAINED_SUBMISSION_BYTES) {
518
+ stopSession();
519
+ throw new Error("session submission limit exceeded");
520
+ }
455
521
  seenEvents.add(event.id);
456
- eventLog.push(event);
522
+ retainedSubmissionBytes += bytes;
523
+ eventNumber += 1;
524
+ const numbered = { ...event, event_number: eventNumber };
525
+ browserEvents.push(numbered);
526
+ browserEventBytes.push(bytes);
457
527
  notifyPullWaiters();
458
528
  });
459
529
 
@@ -466,20 +536,17 @@ async function start(directory) {
466
536
  try { socket?.close(); } catch {}
467
537
  for (const connection of controlConnections) connection.destroy();
468
538
  await new Promise(resolveClose => controlServer.close(() => resolveClose()));
469
- await unlink(socketPath).catch(() => {});
470
- await rm(publishedRoot, { recursive: true, force: true });
471
- if (attachmentInboxPromise) {
472
- try { await rm(await attachmentInboxPromise, { recursive: true, force: true }); } catch {}
473
- }
539
+ await unlink(controlPath(root)).catch(() => {});
474
540
  process.exit(code);
475
541
  };
542
+ stopSession = () => { void stop(1); };
476
543
  process.once("SIGINT", () => void stop(0));
477
544
  process.once("SIGTERM", () => void stop(0));
478
545
 
479
546
  const retry = () => {
480
547
  if (stopped || Date.now() >= retryUntil) return void stop(1);
481
548
  retryTimer = setTimeout(() => { retryTimer = undefined; connect(); }, retryDelay);
482
- retryDelay = Math.min(retryDelay * 2, MAX_RETRY_DELAY);
549
+ retryDelay = Math.min(retryDelay * 2, 5_000);
483
550
  };
484
551
 
485
552
  const connect = () => {
@@ -512,15 +579,11 @@ async function start(directory) {
512
579
  } else if (packet.type === "session") {
513
580
  if (!validSessionUrl(packet.url)) return void stop(1);
514
581
  sessionUrl = packet.url;
515
- if (!initialPublished) {
516
- initialPublished = true;
517
- send({ type: "revision" });
518
- }
519
- if (!ready) { ready = true; process.stdout.write(`${JSON.stringify({ type: "ready", url: sessionUrl, workspace: workspaceId, workspace_sequence: workspaceSequence })}\n`); }
582
+ if (!ready) { ready = true; process.stdout.write(`${JSON.stringify({ type: "ready", url: sessionUrl, page_event: pageEvent, page_hash: currentPageHash() })}\n`); }
520
583
  } else if (packet.type === "http_request") {
521
- const requestRoot = publishedRoot;
522
- const requestWorkspace = workspaceId;
523
- void handleRequest(requestRoot, requestWorkspace, packet, getAttachmentInbox, recordInteraction).then(result => send(result)).catch(() => send(errorResponse(packet, 500, "preview request failed")));
584
+ const requestPage = page;
585
+ const requestPageEvent = pageEvent;
586
+ void handleRequest(root, requestPage, requestPageEvent, packet, recordInteraction).then(result => send(result)).catch(() => send(errorResponse(packet, 500, "preview request failed")));
524
587
  } else if (packet.type === "closed") {
525
588
  void stop(0);
526
589
  } else if (packet.type === "error") {
@@ -534,7 +597,7 @@ async function start(directory) {
534
597
  send = () => false;
535
598
  socket = undefined;
536
599
  if (!credential || !sessionUrl) return void stop(1);
537
- if (!retryUntil) retryUntil = Date.now() + GRACE_SECONDS * 1_000;
600
+ if (!retryUntil) retryUntil = Date.now() + RECONNECT_RETRY_SECONDS * 1_000;
538
601
  retry();
539
602
  });
540
603
  };
@@ -564,7 +627,7 @@ function validSessionUrl(value) {
564
627
  }
565
628
 
566
629
  function usage() {
567
- 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";
630
+ 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> [--updates <file|->]\n";
568
631
  }
569
632
 
570
633
  function commandArgs() {
@@ -576,7 +639,8 @@ function commandArgs() {
576
639
  skill: { type: "boolean" },
577
640
  help: { type: "boolean", short: "h" },
578
641
  wait: { type: "string" },
579
- "based-on": { type: "string" }
642
+ batch: { type: "string" },
643
+ updates: { type: "string" }
580
644
  },
581
645
  allowPositionals: true,
582
646
  strict: true
@@ -585,24 +649,25 @@ function commandArgs() {
585
649
  throw new Error(cause instanceof Error ? cause.message : "invalid arguments");
586
650
  }
587
651
  if (parsed.values.skill || parsed.values.help) {
588
- if (parsed.positionals.length || parsed.values.wait !== undefined || parsed.values["based-on"] !== undefined) throw new Error(usage());
652
+ if (parsed.positionals.length || parsed.values.wait !== undefined || parsed.values.batch !== undefined || parsed.values.updates !== undefined) throw new Error(usage());
589
653
  return { command: parsed.values.skill ? "skill" : "help" };
590
654
  }
591
655
  const [command, directory, ...extra] = parsed.positionals;
592
656
  if (!command || !directory || extra.length) throw new Error(usage());
593
- if (command === "serve" && (parsed.values.wait !== undefined || parsed.values["based-on"] !== undefined)) throw new Error(usage());
594
- if (command === "pull" && parsed.values["based-on"] !== undefined) throw new Error(usage());
595
- if ((command === "push" || command === "ack") && parsed.values.wait !== undefined) throw new Error(usage());
596
- if (!["serve", "pull", "push", "ack"].includes(command)) throw new Error(usage());
657
+ if (!["serve", "show", "pull", "push"].includes(command)) throw new Error(usage());
658
+ if ((command === "serve" || command === "show") && (parsed.values.wait !== undefined || parsed.values.batch !== undefined || parsed.values.updates !== undefined)) throw new Error(usage());
659
+ if (command === "pull" && (parsed.values.batch !== undefined || parsed.values.updates !== undefined)) throw new Error(usage());
660
+ if (command === "push" && parsed.values.wait !== undefined) throw new Error(usage());
597
661
  let wait = 0;
598
662
  if (parsed.values.wait !== undefined) {
599
663
  wait = Number(parsed.values.wait);
600
664
  if (!Number.isFinite(wait) || wait < 0) throw new Error("--wait must be a non-negative number");
601
665
  }
602
- if ((command === "push" || command === "ack") && typeof parsed.values["based-on"] !== "string") throw new Error("--based-on is required");
603
- return { command, directory, wait, token: parsed.values["based-on"] };
666
+ if (command === "push" && typeof parsed.values.batch !== "string") throw new Error("--batch is required");
667
+ return { command, directory, wait, token: parsed.values.batch, updates: parsed.values.updates };
604
668
  }
605
669
 
670
+
606
671
  let command;
607
672
  try {
608
673
  command = commandArgs();
@@ -611,8 +676,15 @@ try {
611
676
  else if (command.command === "serve") await start(command.directory);
612
677
  else {
613
678
  const { root } = await options(command.directory);
614
- const result = await connectControl(root, command.command === "pull" ? { type: "pull", wait_seconds: command.wait } : { type: command.command, token: command.token });
615
- process.stdout.write(`${JSON.stringify(result)}\n`);
679
+ let packet;
680
+ if (command.command === "pull") packet = { type: "pull", wait_seconds: command.wait };
681
+ else if (command.command === "show") packet = { type: "show" };
682
+ else {
683
+ packet = { type: "push", token: command.token, updates: command.updates === undefined ? [] : await readUpdatesInput(command.updates) };
684
+ }
685
+ const result = await connectControl(root, packet);
686
+ if (command.command === "show" && result.ok) process.stdout.write(result.html);
687
+ else process.stdout.write(`${JSON.stringify(result)}\n`);
616
688
  if (!result.ok) process.exitCode = 1;
617
689
  }
618
690
  } catch (cause) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "letmeknow-cli",
3
- "version": "0.6.1",
4
- "description": "A live static preview with agent-readable form submissions.",
3
+ "version": "0.8.0",
4
+ "description": "A live agent-authored page with structured browser feedback.",
5
5
  "files": [
6
6
  "bin",
7
7
  "SKILL.md"
@@ -30,6 +30,7 @@
30
30
  "ws": "^8.21.3"
31
31
  },
32
32
  "dependencies": {
33
- "mrmime": "^2.0.1"
33
+ "mrmime": "^2.0.1",
34
+ "parse5": "^8.0.1"
34
35
  }
35
36
  }