letmeknow-cli 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,64 +1,98 @@
1
1
  # LetMeKnow
2
2
 
3
- LetMeKnow gives an agent a temporary public preview of a dedicated folder and receives structured input from a human. The CLI serves the folder over an outbound connection to the LetMeKnow relay; it does not listen on a network port.
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.
4
4
 
5
5
  ## Start a session
6
6
 
7
- Node.js 22.12 or newer is required. Pass the preview directory explicitly:
7
+ Node.js 22.12 or newer is required. Create a dedicated directory containing only public files, then keep the server running:
8
8
 
9
9
  ```bash
10
- npx letmeknow-cli ./preview
10
+ npx letmeknow-cli serve ./preview
11
11
  ```
12
12
 
13
- The CLI prints JSON lines to stdout. The first line contains the public URL:
13
+ The server prints one JSON line containing the public bearer URL and initial workspace revision:
14
14
 
15
15
  ```json
16
- {"type":"ready","url":"https://0123456789abcdef0123.letmeknow.dev/"}
16
+ {"type":"ready","url":"https://0123456789abcdef0123.letmeknow.dev/","workspace":"…","workspace_sequence":1}
17
17
  ```
18
18
 
19
- Open the URL in one or more browsers. The URL is a bearer capability: anyone who has it can view the preview and submit its forms. Sessions are hosted at `letmeknow.dev` and are temporary. A graceful CLI stop closes the session; an unexpected disconnect can reconnect for up to ten minutes. `--skill` prints instructions for an agent without starting a session. Diagnostics go to stderr.
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.
20
20
 
21
- The preview directory is public. Keep secrets and unrelated project files elsewhere. Use a dedicated directory containing only the files intended for the human.
21
+ ## Publish revisions
22
22
 
23
- ## Live workspace
23
+ `serve` snapshots the initial folder. Later filesystem changes remain private until explicitly published:
24
24
 
25
- Create an ordinary static site in the directory, usually with `index.html`, plus its CSS, JavaScript, images, and other assets. The relay injects the small browser runtime into HTML pages. The CLI serves the workspace files unchanged.
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
+ }
51
+ ```
52
+
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:
26
56
 
27
- Every file in the preview directory is part of the live artifact. A burst of changes is coalesced into one workspace revision. Each connected browser then performs a full-page reload.
57
+ ```bash
58
+ npx letmeknow-cli ack ./preview --based-on <batch-token>
59
+ ```
60
+
61
+ `push` and `ack` are idempotent for a token. They fail if another command has moved the workspace or event cursor first.
62
+
63
+ The commands communicate with `serve` through a private local Unix socket. `--skill` prints agent instructions without starting a session.
28
64
 
29
- The runtime preserves scroll position and the values of controls with stable, unique `id` attributes, including checked and selected state. Other browser state is not part of the preview contract. Use relative asset URLs and normal links between pages.
65
+ ## Workspace behavior
30
66
 
31
- If a requested page does not exist, the live 404 page remains connected and recovers when the page is created. Connection status pages likewise reconnect and recover when the producer becomes available again.
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.
32
70
 
33
71
  ## Forms
34
72
 
35
- Use native HTML GET and POST forms with same-origin or relative actions:
73
+ Use native same-origin GET or POST forms:
36
74
 
37
75
  ```html
38
76
  <form id="decision" action="/decide" method="post">
39
- <label>Comment <textarea name="comment"></textarea></label>
77
+ <label>Comment <textarea id="comment" name="comment"></textarea></label>
40
78
  <button name="decision" value="approve">Approve</button>
41
79
  <button name="decision" value="reject">Reject</button>
42
80
  </form>
43
81
  ```
44
82
 
45
- The runtime sends the submission to the CLI, which prints one `submit` event to stdout:
46
-
47
- ```json
48
- {"type":"submit","id":"…","method":"POST","action":"/decide","form_id":"decision","trigger":{"id":null,"name":"decision","value":"approve"},"values":{"comment":"Looks good","decision":"approve"}}
49
- ```
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.
50
84
 
51
- Repeated field names become arrays, and native browser validation runs before delivery. POST forms may include file inputs; the total submission is limited to 1 MiB. Uploaded files are stored in a private temporary inbox and the event includes an `attachments` array with each field name, original filename, media type, size, and local path. Attachment paths remain available until the CLI stops and are never published unless the agent deliberately copies them into the preview directory.
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.
52
86
 
53
- The event ID identifies the submission; it is not a response handle. Read the event, treat values and attachments as untrusted input, update the workspace, and let the next revision show the result.
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.
54
88
 
55
- Submission feedback is automatic: **Sending…** or **Uploading…**, then **Sent. Waiting for an update…**, or **Couldn’t send. Try again.** Add `[data-letmeknow-status]` where a form or page needs a particular status location. Forms are accepted for asynchronous processing, so the transport response is `202 Accepted`.
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.
56
90
 
57
91
  ## Security
58
92
 
59
- The preview URL grants access to the session. The relay receives served files and submitted values. The preview folder is trusted code from the browser's perspective, so do not include secrets or credentials unless that is intentional. The CLI makes outbound relay connections only and accepts no inbound browser connections.
93
+ The URL is a bearer capability. The relay receives published files and submitted values. Keep secrets and unrelated files outside the preview directory.
60
94
 
61
- The CLI resolves requested paths and denies symlinks whose targets leave the selected directory. Sensitive names such as `.env`, `.git`, private keys, and database files are excluded. Processes with write access to the preview directory are trusted publishers and can make data public through it.
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.
62
96
 
63
97
  ## Development
64
98
 
@@ -70,4 +104,4 @@ npm run dev
70
104
  npm run deploy
71
105
  ```
72
106
 
73
- `npm run test:browser` requires Firefox and geckodriver. `npm run dev` and `npm run deploy` operate the Cloudflare relay. The stdout contract is JSONL (`ready` and `submit` events); diagnostics belong on stderr.
107
+ The browser test requires Firefox and geckodriver. `npm run dev` and `npm run deploy` operate the Cloudflare relay.
package/SKILL.md CHANGED
@@ -1,73 +1,89 @@
1
1
  ---
2
2
  name: letmeknow
3
- description: Give a human a temporary live preview of an agent-managed folder and receive browser form submissions as JSON lines.
3
+ description: Publish a temporary browser workspace, pull structured human feedback, and push coherent agent revisions.
4
4
  ---
5
5
 
6
6
  # LetMeKnow
7
7
 
8
- Use LetMeKnow when a human needs to inspect or interact with a temporary page, report, dashboard, approval form, quiz, table, or status view. You own the files in a dedicated preview folder. The CLI serves that folder through the hosted LetMeKnow relay and reports browser submissions on stdout. It does not listen on a local network port.
8
+ Use LetMeKnow when a human should inspect or interact with an agent-managed page, report, dashboard, approval, quiz, table, or prototype.
9
9
 
10
10
  ## Start
11
11
 
12
- Pass only the files intended for public viewing in an explicit directory:
12
+ Create a dedicated directory containing only public files and keep the server running:
13
13
 
14
14
  ```bash
15
- npx letmeknow-cli ./preview
15
+ npx letmeknow-cli serve ./preview
16
16
  ```
17
17
 
18
- Node.js 22.12 or newer is required. Keep the process running while the human uses the page. Read stdout and stderr separately. Stdout is JSONL; the first event is:
18
+ Node.js 22.12 or newer is required. The first stdout JSON line contains the bearer URL and initial workspace revision:
19
19
 
20
20
  ```json
21
- {"type":"ready","url":"https://0123456789abcdef0123.letmeknow.dev/"}
21
+ {"type":"ready","url":"https://0123456789abcdef0123.letmeknow.dev/","workspace":"…","workspace_sequence":1}
22
22
  ```
23
23
 
24
- Give the human the URL. It is a bearer capability, so anyone with it can view the preview and submit forms. Multiple browsers may view the session. `--skill` prints these instructions without starting a session.
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.
25
25
 
26
- ## Build the preview
26
+ ## Agent loop
27
27
 
28
- Create an ordinary static site in the folder, usually `index.html`, with its CSS, JavaScript, images, and other assets. Use semantic HTML, accessible labels, and relative asset URLs. Use normal links for navigation.
28
+ Filesystem writes are private drafts. Explicitly pull feedback and publish coherent revisions:
29
29
 
30
- The relay injects the browser runtime into HTML. All files in the folder form one live workspace. Changes made in a short burst become one revision, and connected browsers perform a full-page reload. The runtime preserves scroll position and values, checked state, and selected state for controls with stable, unique `id` attributes.
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>
34
+ ```
35
+
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
+
38
+ When a batch needs no visible workspace change:
39
+
40
+ ```bash
41
+ npx letmeknow-cli ack ./preview --based-on <batch-token>
42
+ ```
31
43
 
32
- A missing page displays a live 404 page that can recover when you create the page. Connection status pages reconnect and recover when the producer is available again.
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.
33
45
 
34
- ## Receive form submissions
46
+ A push may publish independent work from an empty batch. Use the token returned by an empty `pull`.
35
47
 
36
- Use native GET and POST forms with relative or same-origin actions:
48
+ ## Build the workspace
49
+
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.
51
+
52
+ Use native same-origin GET or POST forms:
37
53
 
38
54
  ```html
39
- <form id="search" action="/search" method="post">
40
- <label>Query <input name="query" required></label>
41
- <button name="scope" value="all">Search all</button>
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>
42
59
  </form>
43
60
  ```
44
61
 
45
- The runtime provides automatic transport feedback: **Sending…** or **Uploading…**, **Sent. Waiting for an update…**, or **Couldn’t send. Try again.** Add `[data-letmeknow-status]` for a custom status location. The transport accepts submissions asynchronously with `202 Accepted`.
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.
46
63
 
47
- Read stdout for a `submit` event:
64
+ A pulled event includes the workspace the human saw:
48
65
 
49
66
  ```json
50
- {"type":"submit","id":"…","method":"POST","action":"/search","form_id":"search","trigger":{"id":null,"name":"scope","value":"all"},"values":{"query":"quarterly report","scope":"all"}}
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
+ }
51
79
  ```
52
80
 
53
- Give forms stable IDs and controls meaningful `name` values. Native browser validation runs before delivery, and repeated names become arrays. POST forms may include file inputs within the 1 MiB total submission limit. Attachment metadata includes a local temporary path that remains readable until the CLI stops; treat the filename, media type, and contents as untrusted. Attachments are private unless you deliberately copy them into the preview folder.
54
-
55
- The event ID identifies the submission, not a response channel: validate the values and attachments, update the files, and let the next workspace revision show the result. Do not write commands to stdin.
56
-
57
- ## Example workflow
58
-
59
- 1. Build the initial page in `index.html`.
60
- 2. Wait for a `submit` event on stdout.
61
- 3. Validate its values and action.
62
- 4. Rewrite the relevant HTML or data file in the preview folder.
63
- 5. Let the workspace revision reload the human's page.
64
-
65
- Escape untrusted values before placing them in HTML.
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.
66
82
 
67
- ## Security
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.
68
84
 
69
- The preview folder is public and is trusted code from the browser's perspective. Keep secrets and unrelated project files elsewhere. The CLI restricts requests to the selected directory and excludes `.env`, `.git`, private-key, and database files. Treat every process with write access to the preview folder as a trusted publisher. The CLI makes outbound relay connections only and accepts no inbound browser connections.
85
+ Escape untrusted text before placing it in HTML.
70
86
 
71
87
  ## Stop
72
88
 
73
- Send `SIGINT` or `SIGTERM` to close the session. After an unexpected disconnect, the CLI can reconnect to the session for up to ten minutes. Diagnostics go to stderr; stdout remains JSONL.
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.
package/bin/letmeknow.js CHANGED
@@ -1,19 +1,21 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { constants, existsSync, readFileSync, statSync, writeSync } from "node:fs";
4
- import { mkdtemp, open, realpath, rm, stat, writeFile } from "node:fs/promises";
5
- import { randomUUID } from "node:crypto";
6
- import { tmpdir } from "node:os";
4
+ import { chmod, copyFile, lstat, mkdtemp, mkdir, open, readdir, readlink, realpath, rm, stat, symlink, unlink, writeFile } from "node:fs/promises";
5
+ import { createHash, randomUUID } from "node:crypto";
6
+ import net from "node:net";
7
7
  import { dirname, extname, join, relative, resolve, sep } from "node:path";
8
+ import { tmpdir } from "node:os";
8
9
  import { parseArgs } from "node:util";
9
- import chokidar from "chokidar";
10
10
  import { lookup } from "mrmime";
11
11
 
12
12
  const MAX_BODY_BYTES = 1024 * 1024;
13
13
  const GRACE_SECONDS = 10 * 60;
14
14
  const CONNECTION_TIMEOUT = 10_000;
15
+ const CONTROL_TIMEOUT = 35_000;
15
16
  const MAX_RETRY_DELAY = 5_000;
16
- const REVISION_QUIET_MS = 200;
17
+ const CONTROL_PREFIX = "letmeknow-control-";
18
+ const SNAPSHOT_PREFIX = "letmeknow-snapshot-";
17
19
  const CONTROL_URL = "https://letmeknow.dev";
18
20
  const credentialPattern = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
19
21
  const privateNames = new Set([".env", ".git", ".ssh", "id_rsa", "id_ed25519", "id_ecdsa", "id_dsa"]);
@@ -91,7 +93,8 @@ function requestUrl(packet) {
91
93
  return { pathname, encodedPathname: url.pathname, search: url.search };
92
94
  }
93
95
 
94
- async function staticResponse(root, packet) {
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 });
95
98
  const method = typeof packet.method === "string" ? packet.method.toUpperCase() : "";
96
99
  if (method !== "GET" && method !== "HEAD") return errorResponse(packet, 405, "method not allowed");
97
100
  let request;
@@ -113,7 +116,7 @@ async function staticResponse(root, packet) {
113
116
  if (info.isDirectory()) {
114
117
  if (!request.encodedPathname.endsWith("/")) {
115
118
  const location = request.encodedPathname.slice(request.encodedPathname.lastIndexOf("/") + 1) + "/" + request.search;
116
- return response(packet, 301, Buffer.from(`Redirecting to ${location}`), { Location: location, "Content-Type": "text/plain; charset=utf-8" });
119
+ return published(301, Buffer.from(`Redirecting to ${location}`), { Location: location, "Content-Type": "text/plain; charset=utf-8" });
117
120
  }
118
121
  const index = resolve(target, "index.html");
119
122
  try { target = await realpath(index); } catch (cause) {
@@ -140,7 +143,7 @@ async function staticResponse(root, packet) {
140
143
  if (info.size > MAX_BODY_BYTES) return errorResponse(packet, 413, "response body is too large");
141
144
  const body = await file.readFile();
142
145
  if (body.byteLength > MAX_BODY_BYTES) return errorResponse(packet, 413, "response body is too large");
143
- return response(packet, 200, body, { "Content-Type": getMimeType(target) });
146
+ return published(200, body, { "Content-Type": getMimeType(target) });
144
147
  } catch {
145
148
  return errorResponse(packet, 500, "preview request failed");
146
149
  } finally {
@@ -170,7 +173,7 @@ async function multipartSubmission(body, contentType, getAttachmentInbox) {
170
173
  return { values, attachments };
171
174
  }
172
175
 
173
- async function submission(packet, getAttachmentInbox) {
176
+ async function submission(packet, getAttachmentInbox, recordInteraction) {
174
177
  const url = requestUrl(packet);
175
178
  const method = typeof packet.method === "string" ? packet.method.toUpperCase() : "";
176
179
  const values = Object.create(null);
@@ -192,23 +195,25 @@ async function submission(packet, getAttachmentInbox) {
192
195
  } else throw new Error("unsupported submission method");
193
196
  const event = {
194
197
  type: "submit",
195
- id: encodedHeader(packet, "x-letmeknow-id"),
198
+ id: encodedHeader(packet, "x-letmeknow-id") || randomUUID(),
196
199
  method,
197
200
  action: encodedHeader(packet, "x-letmeknow-action") || url.pathname,
198
201
  form_id: encodedHeader(packet, "x-letmeknow-form-id"),
199
202
  trigger: { id: encodedHeader(packet, "x-letmeknow-trigger-id"), name: encodedHeader(packet, "x-letmeknow-trigger-name"), value: encodedHeader(packet, "x-letmeknow-trigger-value") },
200
203
  values
201
204
  };
205
+ const basedOn = encodedHeader(packet, "x-letmeknow-based-on");
206
+ if (basedOn !== null) event.based_on = basedOn;
202
207
  if (attachments?.length) event.attachments = attachments;
203
- process.stdout.write(`${JSON.stringify(event)}\n`);
208
+ await recordInteraction(event);
204
209
  return response(packet, 202);
205
210
  }
206
211
 
207
- async function handleRequest(root, packet, getAttachmentInbox) {
212
+ async function handleRequest(root, workspaceId, packet, getAttachmentInbox, recordInteraction) {
208
213
  if (header(packet, "x-letmeknow-submission") === "1") {
209
- try { return await submission(packet, getAttachmentInbox); } catch (cause) { return errorResponse(packet, cause?.message === "submission is too large" ? 413 : 400, cause instanceof Error ? cause.message : "invalid submission"); }
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"); }
210
215
  }
211
- return staticResponse(root, packet);
216
+ return staticResponse(root, packet, workspaceId);
212
217
  }
213
218
 
214
219
  function options(directory) {
@@ -217,24 +222,85 @@ function options(directory) {
217
222
  return realpath(root).then(root => ({ root }));
218
223
  }
219
224
 
220
- function endpoint(credential, sessionUrl) {
221
- const url = new URL(CONTROL_URL);
222
- url.protocol = "wss:";
223
- url.pathname = "/v2/connect";
224
- if (credential && sessionUrl) {
225
- const publicUrl = new URL(sessionUrl);
226
- const code = publicUrl.hostname.match(/^([a-f0-9]{20})\.letmeknow\.dev$/)?.[1];
227
- if (!code) throw new Error("invalid session URL");
228
- url.searchParams.set("code", code);
225
+ function controlPath(root) {
226
+ const key = createHash("sha256").update(root).digest("hex").slice(0, 32);
227
+ return join(tmpdir(), `${CONTROL_PREFIX}${key}.sock`);
228
+ }
229
+
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);
229
250
  }
230
- return url;
251
+ visited.delete(sourceReal);
231
252
  }
232
253
 
233
- function validSessionUrl(value) {
234
- if (typeof value !== "string") return false;
235
- let url;
236
- try { url = new URL(value); } catch { return false; }
237
- return url.protocol === "https:" && /^[a-f0-9]{20}\.letmeknow\.dev$/.test(url.hostname) && url.pathname === "/" && !url.search && !url.hash;
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
+ function mutateQueue() {
266
+ let chain = Promise.resolve();
267
+ return operation => {
268
+ const previous = chain;
269
+ let release;
270
+ chain = new Promise(resolve => { release = resolve; });
271
+ return previous.then(operation).finally(release);
272
+ };
273
+ }
274
+
275
+ function connectControl(root, packet) {
276
+ const timeout = Math.max(CONTROL_TIMEOUT, ((packet.wait_seconds || 0) + 5) * 1_000);
277
+ return new Promise((resolve, reject) => {
278
+ const socket = net.createConnection(controlPath(root));
279
+ let output = "";
280
+ let settled = false;
281
+ const timer = setTimeout(() => {
282
+ socket.destroy();
283
+ reject(new Error("control request timed out"));
284
+ }, timeout);
285
+ const finish = (cause, value) => {
286
+ if (settled) return;
287
+ settled = true;
288
+ clearTimeout(timer);
289
+ if (cause) reject(cause);
290
+ else resolve(value);
291
+ };
292
+ socket.setEncoding("utf8");
293
+ socket.on("connect", () => socket.write(JSON.stringify(packet) + "\n"));
294
+ socket.on("data", chunk => {
295
+ output += chunk;
296
+ const newline = output.indexOf("\n");
297
+ if (newline < 0) return;
298
+ try { finish(null, JSON.parse(output.slice(0, newline))); } catch (cause) { finish(cause); }
299
+ socket.destroy();
300
+ });
301
+ socket.on("error", cause => finish(new Error(`serve is not running: ${cause.message}`)));
302
+ socket.on("close", () => { if (!settled) finish(new Error("serve closed the control connection")); });
303
+ });
238
304
  }
239
305
 
240
306
  async function start(directory) {
@@ -244,28 +310,20 @@ async function start(directory) {
244
310
  attachmentInboxPromise ??= mkdtemp(join(tmpdir(), "letmeknow-attachments-"));
245
311
  return attachmentInboxPromise;
246
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;
320
+ const seenEvents = new Set();
321
+ const tokens = new Map();
322
+ const pendingTokens = new Map();
323
+ const pullWaiters = new Set();
324
+ const mutate = mutateQueue();
325
+ let controlServer;
247
326
  let send = () => false;
248
- let revisionTimer;
249
- const watchedPath = filename => {
250
- const file = resolve(root, String(filename));
251
- const path = relative(root, file).split(sep).join("/");
252
- return path && path !== ".." && !path.startsWith("../") && !deniedPath("/" + path);
253
- };
254
- const watcher = chokidar.watch(root, {
255
- ignoreInitial: true,
256
- ignored: filename => {
257
- const path = relative(root, resolve(root, String(filename))).split(sep).join("/");
258
- return path !== "" && (path === ".." || path.startsWith("../") || deniedPath("/" + path));
259
- }
260
- });
261
- const scheduleRevision = () => {
262
- clearTimeout(revisionTimer);
263
- revisionTimer = setTimeout(() => { revisionTimer = undefined; send({ type: "revision" }); }, REVISION_QUIET_MS);
264
- };
265
- watcher.on("all", (_event, filename) => {
266
- if (filename && !watchedPath(filename)) return;
267
- scheduleRevision();
268
- });
269
327
  let socket;
270
328
  let credential;
271
329
  let sessionUrl;
@@ -275,15 +333,141 @@ async function start(directory) {
275
333
  let retryUntil = 0;
276
334
  let stopped = false;
277
335
  let ready = false;
336
+ let initialPublished = false;
337
+
338
+ 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"
351
+ }
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;
357
+ };
358
+
359
+ const notifyPullWaiters = () => {
360
+ for (const waiter of [...pullWaiters]) {
361
+ if (eventLog.length === committedCursor) continue;
362
+ pullWaiters.delete(waiter);
363
+ clearTimeout(waiter.timer);
364
+ waiter.resolve(batch());
365
+ }
366
+ };
367
+
368
+ const pull = waitSeconds => {
369
+ if (eventLog.length > committedCursor || waitSeconds <= 0) return Promise.resolve(batch());
370
+ return new Promise(resolve => {
371
+ const waiter = { resolve, timer: setTimeout(() => { pullWaiters.delete(waiter); resolve(batch()); }, waitSeconds * 1_000) };
372
+ pullWaiters.add(waiter);
373
+ });
374
+ };
375
+
376
+ const commit = async (token, publish) => {
377
+ const record = tokens.get(token);
378
+ 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;
385
+ }
386
+ if (record.parent !== workspaceId || record.start !== committedCursor) {
387
+ pendingTokens.delete(record.key);
388
+ record.status = "failed";
389
+ record.result = { ok: false, error: "batch is based on an old workspace or cursor", current_workspace: workspaceId, frontier: committedCursor };
390
+ return record.result;
391
+ }
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) };
403
+ }
404
+ committedCursor = record.end;
405
+ pendingTokens.delete(record.key);
406
+ record.status = "committed";
407
+ if (publish) send({ type: "revision" });
408
+ return record.result;
409
+ };
410
+
411
+ const dispatchControl = async request => {
412
+ if (!request || typeof request !== "object") return { ok: false, error: "invalid control request" };
413
+ 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);
416
+ return { ok: false, error: "unknown control request" };
417
+ };
418
+
419
+ const controlConnections = new Set();
420
+ controlServer = net.createServer(connection => {
421
+ controlConnections.add(connection);
422
+ connection.setEncoding("utf8");
423
+ let input = "";
424
+ let handled = false;
425
+ connection.on("data", async chunk => {
426
+ input += chunk;
427
+ if (input.length > MAX_BODY_BYTES || handled) return;
428
+ const newline = input.indexOf("\n");
429
+ if (newline < 0) return;
430
+ handled = true;
431
+ let result;
432
+ try {
433
+ const request = JSON.parse(input.slice(0, newline));
434
+ result = request.type === "pull" ? await dispatchControl(request) : await mutate(() => dispatchControl(request));
435
+ } catch (cause) { result = { ok: false, error: cause instanceof Error ? cause.message : "control request failed" }; }
436
+ connection.end(JSON.stringify(result) + "\n");
437
+ });
438
+ connection.on("close", () => controlConnections.delete(connection));
439
+ connection.on("error", () => controlConnections.delete(connection));
440
+ });
441
+ await new Promise((resolveListen, reject) => {
442
+ controlServer.once("error", reject);
443
+ controlServer.listen(socketPath, async () => {
444
+ try { await chmod(socketPath, 0o600); } catch (cause) { controlServer.close(() => reject(cause)); return; }
445
+ controlServer.off("error", reject);
446
+ resolveListen();
447
+ });
448
+ }).catch(async cause => {
449
+ await rm(publishedRoot, { recursive: true, force: true });
450
+ throw new Error(`cannot start local control channel: ${cause.message}`);
451
+ });
452
+
453
+ const recordInteraction = event => mutate(async () => {
454
+ if (seenEvents.has(event.id)) return;
455
+ seenEvents.add(event.id);
456
+ eventLog.push(event);
457
+ notifyPullWaiters();
458
+ });
459
+
278
460
  const stop = async code => {
279
461
  if (stopped) return;
280
462
  stopped = true;
281
463
  clearTimeout(retryTimer);
282
464
  clearTimeout(connectionTimer);
283
- clearTimeout(revisionTimer);
284
465
  send({ type: "close" });
285
466
  try { socket?.close(); } catch {}
286
- await watcher.close();
467
+ for (const connection of controlConnections) connection.destroy();
468
+ await new Promise(resolveClose => controlServer.close(() => resolveClose()));
469
+ await unlink(socketPath).catch(() => {});
470
+ await rm(publishedRoot, { recursive: true, force: true });
287
471
  if (attachmentInboxPromise) {
288
472
  try { await rm(await attachmentInboxPromise, { recursive: true, force: true }); } catch {}
289
473
  }
@@ -328,9 +512,15 @@ async function start(directory) {
328
512
  } else if (packet.type === "session") {
329
513
  if (!validSessionUrl(packet.url)) return void stop(1);
330
514
  sessionUrl = packet.url;
331
- if (!ready) { ready = true; process.stdout.write(`${JSON.stringify({ type: "ready", url: sessionUrl })}\n`); }
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`); }
332
520
  } else if (packet.type === "http_request") {
333
- void handleRequest(root, packet, getAttachmentInbox).then(result => send(result)).catch(() => send(errorResponse(packet, 500, "preview request failed")));
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")));
334
524
  } else if (packet.type === "closed") {
335
525
  void stop(0);
336
526
  } else if (packet.type === "error") {
@@ -353,29 +543,79 @@ async function start(directory) {
353
543
  await new Promise(() => {});
354
544
  }
355
545
 
356
- let parsed;
357
- try {
358
- parsed = parseArgs({
359
- args: process.argv.slice(2),
360
- options: {
361
- skill: { type: "boolean" },
362
- help: { type: "boolean", short: "h" }
363
- },
364
- allowPositionals: true
365
- });
366
- } catch (cause) {
367
- process.stderr.write(`letmeknow: ${cause instanceof Error ? cause.message : "invalid arguments"}\n`);
368
- process.exit(1);
546
+ function endpoint(credential, sessionUrl) {
547
+ const url = new URL(CONTROL_URL);
548
+ url.protocol = "wss:";
549
+ url.pathname = "/v2/connect";
550
+ if (credential && sessionUrl) {
551
+ const publicUrl = new URL(sessionUrl);
552
+ const code = publicUrl.hostname.match(/^([a-f0-9]{20})\.letmeknow\.dev$/)?.[1];
553
+ if (!code) throw new Error("invalid session URL");
554
+ url.searchParams.set("code", code);
555
+ }
556
+ return url;
557
+ }
558
+
559
+ function validSessionUrl(value) {
560
+ if (typeof value !== "string") return false;
561
+ let url;
562
+ try { url = new URL(value); } catch { return false; }
563
+ return url.protocol === "https:" && /^[a-f0-9]{20}\.letmeknow\.dev$/.test(url.hostname) && url.pathname === "/" && !url.search && !url.hash;
564
+ }
565
+
566
+ 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";
568
+ }
569
+
570
+ function commandArgs() {
571
+ let parsed;
572
+ try {
573
+ parsed = parseArgs({
574
+ args: process.argv.slice(2),
575
+ options: {
576
+ skill: { type: "boolean" },
577
+ help: { type: "boolean", short: "h" },
578
+ wait: { type: "string" },
579
+ "based-on": { type: "string" }
580
+ },
581
+ allowPositionals: true,
582
+ strict: true
583
+ });
584
+ } catch (cause) {
585
+ throw new Error(cause instanceof Error ? cause.message : "invalid arguments");
586
+ }
587
+ 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());
589
+ return { command: parsed.values.skill ? "skill" : "help" };
590
+ }
591
+ const [command, directory, ...extra] = parsed.positionals;
592
+ 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());
597
+ let wait = 0;
598
+ if (parsed.values.wait !== undefined) {
599
+ wait = Number(parsed.values.wait);
600
+ if (!Number.isFinite(wait) || wait < 0) throw new Error("--wait must be a non-negative number");
601
+ }
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"] };
369
604
  }
370
605
 
371
- if (parsed.values.skill) {
372
- if (parsed.positionals.length > 0) { process.stderr.write("Usage: npx letmeknow-cli --skill\n"); process.exit(1); }
373
- writeSync(1, readFileSync(new URL("../SKILL.md", import.meta.url)));
374
- } else if (parsed.values.help) {
375
- process.stdout.write("Usage: npx letmeknow-cli <directory>\n\nServe a folder through the hosted LetMeKnow relay. The CLI does not listen on a network port. Form submissions are JSON lines on stdout.\n");
376
- } else if (parsed.positionals.length !== 1) {
377
- process.stderr.write("letmeknow: exactly one directory must be provided\n");
378
- process.exit(1);
379
- } else {
380
- try { await start(parsed.positionals[0]); } catch (cause) { process.stderr.write(`letmeknow: ${cause instanceof Error ? cause.message : "server failed"}\n`); process.exitCode = 1; }
606
+ let command;
607
+ try {
608
+ command = commandArgs();
609
+ if (command.command === "skill") writeSync(1, readFileSync(new URL("../SKILL.md", import.meta.url)));
610
+ else if (command.command === "help") process.stdout.write(usage());
611
+ else if (command.command === "serve") await start(command.directory);
612
+ else {
613
+ 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`);
616
+ if (!result.ok) process.exitCode = 1;
617
+ }
618
+ } catch (cause) {
619
+ process.stderr.write(`letmeknow: ${cause instanceof Error ? cause.message : "command failed"}\n`);
620
+ process.exitCode = 1;
381
621
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "letmeknow-cli",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
4
4
  "description": "A live static preview with agent-readable form submissions.",
5
5
  "files": [
6
6
  "bin",
@@ -30,7 +30,6 @@
30
30
  "ws": "^8.21.3"
31
31
  },
32
32
  "dependencies": {
33
- "chokidar": "^5.0.0",
34
33
  "mrmime": "^2.0.1"
35
34
  }
36
35
  }