letmeknow-cli 0.4.11 → 0.6.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 +58 -29
  2. package/SKILL.md +47 -56
  3. package/bin/letmeknow.js +389 -181
  4. package/package.json +3 -4
package/README.md CHANGED
@@ -1,78 +1,107 @@
1
1
  # LetMeKnow
2
2
 
3
- LetMeKnow gives an agent-managed folder a public, live static preview and receives browser form submissions. The CLI serves files directly and makes only an outbound WebSocket connection to the 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
- ## Start
5
+ ## Start a session
6
6
 
7
- Node.js 22.12 or newer is required.
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 ./workspace
10
+ npx letmeknow-cli serve ./preview
11
11
  ```
12
12
 
13
- The CLI prints JSON lines to stdout. The first line contains the public preview 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 that URL in one browser. The directory defaults to the current working directory. Set `LETMEKNOW_URL` to use another compatible relay:
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
+
21
+ ## Publish revisions
22
+
23
+ `serve` snapshots the initial folder. Later filesystem changes remain private until explicitly published:
20
24
 
21
25
  ```bash
22
- LETMEKNOW_URL=https://letmeknow.dev npx letmeknow-cli ./workspace
26
+ npx letmeknow-cli pull ./preview --wait 30
27
+ npx letmeknow-cli push ./preview --based-on <batch-token>
23
28
  ```
24
29
 
25
- There are no `--host` or `--port` options because the CLI intentionally has no listening network socket. Diagnostics go to stderr. `--skill` prints the agent instructions without starting a session.
30
+ `pull` returns pending browser interactions, the current workspace, and an opaque batch token:
26
31
 
27
- ## File workflow
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
+ ```
28
52
 
29
- The CLI does not receive file commands. The agent reads and writes the directory directly. Keep an `index.html` at the root, plus ordinary JavaScript, CSS, images, and other static assets.
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.
30
54
 
31
- The preview serves exact files and `index.html` for directory paths. It supports GET and HEAD, redirects directory paths to a trailing slash, and has no application-shell fallback. HTML responses include the live-preview client inline. Use relative asset URLs so previews also work on path-based session URLs.
55
+ If a batch requires no workspace change, commit it without publishing:
32
56
 
33
- The client continuously saves form values, checked controls, selections, focus, text selection, scroll position, and open `<details>` elements for each page in the tab's session storage. It restores them after file-triggered and user-triggered reloads. CSS changes cache-bust matching stylesheets without navigating, while changes to the current HTML route or another asset reload the page. Changes to a different HTML route do not disturb the current page. Arbitrary JavaScript heap state cannot be preserved.
57
+ ```bash
58
+ npx letmeknow-cli ack ./preview --based-on <batch-token>
59
+ ```
34
60
 
35
- An optional element can display submission status:
61
+ `push` and `ack` are idempotent for a token. They fail if another command has moved the workspace or event cursor first.
36
62
 
37
- ```html
38
- <p data-letmeknow-status aria-live="polite"></p>
39
- ```
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.
40
68
 
41
- ## Form submissions
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.
42
70
 
43
- Forms are submitted without navigation. GET and POST forms are sent through the relay to the CLI, which prints each submission as one JSON line on stdout. The agent can read that line and edit the folder in response.
71
+ ## Forms
72
+
73
+ Use native same-origin GET or POST forms:
44
74
 
45
75
  ```html
46
76
  <form id="decision" action="/decide" method="post">
47
- <label>Comment <textarea name="comment"></textarea></label>
77
+ <label>Comment <textarea id="comment" name="comment"></textarea></label>
48
78
  <button name="decision" value="approve">Approve</button>
49
79
  <button name="decision" value="reject">Reject</button>
50
80
  </form>
51
81
  ```
52
82
 
53
- Submitting `Approve` prints an event like:
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.
54
84
 
55
- ```json
56
- {"type":"submit","id":"…","method":"POST","action":"/decide","form_id":"decision","trigger":{"id":null,"name":"decision","value":"approve"},"values":{"comment":"Looks good","decision":"approve"}}
57
- ```
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.
58
86
 
59
- Repeated field names become arrays. Native browser validation still runs before a submission is sent. File inputs and cross-origin form actions are not supported. Forms can use a submitter's standard `formaction`, `formmethod`, and `name`/`value` attributes.
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.
60
88
 
61
- The event ID identifies the submission. It is not a request/response handle: update the files and let the live preview show the result.
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.
62
90
 
63
91
  ## Security
64
92
 
65
- The preview URL is a bearer capability. The relay receives the served files and submitted values. Do not put secrets in the preview folder or submit credentials unless that is intentional. The folder is trusted executable code from the browser's perspective.
93
+ The URL is a bearer capability. The relay receives published files and submitted values. Keep secrets and unrelated files outside the preview directory.
66
94
 
67
- The selected folder is resolved with real paths. Requests that would leave that folder through a symlink are denied. `.env` files, `.git`, private-key files, database files, and their descendants are denied. The CLI itself still requires an outbound network connection to the relay. It does not accept inbound browser connections.
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.
68
96
 
69
97
  ## Development
70
98
 
71
99
  ```bash
72
100
  npm install
73
101
  npm test
102
+ npm run test:browser
74
103
  npm run dev
75
104
  npm run deploy
76
105
  ```
77
106
 
78
- `npm run dev` and `npm run deploy` operate the Cloudflare relay.
107
+ The browser test requires Firefox and geckodriver. `npm run dev` and `npm run deploy` operate the Cloudflare relay.
package/SKILL.md CHANGED
@@ -1,98 +1,89 @@
1
1
  ---
2
2
  name: letmeknow
3
- description: Give a human a live public 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 one human needs to inspect or interact with a temporary page, report, dashboard, approval form, quiz, table, or status view. The agent owns the files in a folder. The CLI serves that folder directly, connects to the public relay over an outbound WebSocket, and reports browser submissions on stdout.
9
-
10
- The CLI 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.
11
9
 
12
10
  ## Start
13
11
 
14
- Run the CLI as a long-lived child process with the folder you will edit:
12
+ Create a dedicated directory containing only public files and keep the server running:
15
13
 
16
14
  ```bash
17
- npx letmeknow-cli ./workspace
15
+ npx letmeknow-cli serve ./preview
18
16
  ```
19
17
 
20
- Node.js 22.12 or newer is required. 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:
21
19
 
22
20
  ```json
23
- {"type":"ready","url":"https://0123456789abcdef0123.letmeknow.dev/"}
21
+ {"type":"ready","url":"https://0123456789abcdef0123.letmeknow.dev/","workspace":"…","workspace_sequence":1}
24
22
  ```
25
23
 
26
- Open that URL for the human. The directory defaults to the current working directory. Set `LETMEKNOW_URL` to use another compatible relay. There are no host or port options: the CLI intentionally has no listening socket.
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.
27
25
 
28
- Do not send file commands to stdin. Read and write the folder directly. Keep the process running while the human uses the page.
26
+ ## Agent loop
29
27
 
30
- ## Build the page
28
+ Filesystem writes are private drafts. Explicitly pull feedback and publish coherent revisions:
31
29
 
32
- Create an ordinary static page in the folder, usually `index.html`, plus any CSS, JavaScript, images, or other assets it needs. Use relative asset URLs so path-based preview URLs work. Use semantic HTML and accessible labels, headings, sections, tables, and controls.
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
+ ```
33
35
 
34
- The relay serves exact files and directory `index.html` files. It supports GET and HEAD, redirects directory paths to a trailing slash, and does not provide an application-shell fallback. HTML responses include the live-preview client inline.
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.
35
37
 
36
- The preview continuously saves each page's form values, checked controls, selections, focus, text selection, scroll position, and open `<details>` elements in the tab's session storage. It restores them after file-triggered and user-triggered reloads.
38
+ When a batch needs no visible workspace change:
37
39
 
38
- The preview is live:
40
+ ```bash
41
+ npx letmeknow-cli ack ./preview --based-on <batch-token>
42
+ ```
39
43
 
40
- - A change to the current HTML route reloads the page with its saved state.
41
- - CSS changes cache-bust matching linked stylesheets without navigating.
42
- - Changes to a different HTML route do not disturb the current page.
43
- - Changes to other assets reload the page. Arbitrary JavaScript heap state cannot be preserved.
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.
44
45
 
45
- An optional status element gives the human feedback after a form submission:
46
+ A push may publish independent work from an empty batch. Use the token returned by an empty `pull`.
46
47
 
47
- ```html
48
- <p data-letmeknow-status aria-live="polite"></p>
49
- ```
48
+ ## Build the workspace
50
49
 
51
- ## Receive form submissions
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.
52
51
 
53
- GET and POST forms are intercepted before navigation and sent through the relay to the CLI. Read stdout for a `submit` event:
52
+ Use native same-origin GET or POST forms:
54
53
 
55
54
  ```html
56
- <form id="search" action="/search" method="post">
57
- <label>Query <input name="query" required></label>
58
- <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>
59
59
  </form>
60
60
  ```
61
61
 
62
- The event is:
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.
63
+
64
+ A pulled event includes the workspace the human saw:
63
65
 
64
66
  ```json
65
- {"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
+ }
66
79
  ```
67
80
 
68
- Rules:
69
-
70
- - Give interactive forms a stable, meaningful `id`.
71
- - Give controls meaningful `name` values.
72
- - Use normal relative or same-origin actions.
73
- - Use `formaction` and `formmethod` on submitters when different buttons have different intents.
74
- - Native `required`, input types, ranges, and patterns validate in the browser before the event is sent.
75
- - Repeated names become string arrays.
76
- - File inputs and cross-origin actions are not supported.
77
-
78
- The event ID identifies the submission. There is no response packet. Validate its values, edit the files, and let the live preview show the result. Do not write JSON commands to stdin.
79
-
80
- ## Example response workflow
81
-
82
- 1. Render the initial state in `index.html`.
83
- 2. Wait for a `submit` event on stdout.
84
- 3. Validate its `values` and `action`.
85
- 4. Rewrite the relevant HTML or data file in the workspace.
86
- 5. The agent updates the live preview.
87
-
88
- Escape untrusted values before placing them in HTML. Treat browser input as untrusted even though the folder is local to the agent.
89
-
90
- ## Security
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.
91
82
 
92
- The public URL is a bearer capability. The relay receives the served files and submitted values. Do not put secrets in the preview folder or submit credentials unless that is intentional. The folder is trusted executable code from the browser's perspective.
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.
93
84
 
94
- The selected folder is resolved with real paths, and requests cannot escape it through symlinks. `.env` files, `.git`, private-key files, and database files are denied. The CLI makes outbound relay connections only; it does not accept inbound browser connections.
85
+ Escape untrusted text before placing it in HTML.
95
86
 
96
87
  ## Stop
97
88
 
98
- Send `SIGINT` or `SIGTERM` to stop the CLI. The relay session expires after producer disconnect. `--skill` prints these instructions without starting a session.
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,20 +1,25 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { existsSync, readFileSync, statSync, writeSync } from "node:fs";
4
- import { readFile, realpath, stat } from "node:fs/promises";
5
- import { dirname, extname, relative, resolve, sep } from "node:path";
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";
5
+ import { createHash, randomUUID } from "node:crypto";
6
+ import net from "node:net";
7
+ import { dirname, extname, join, relative, resolve, sep } from "node:path";
8
+ import { tmpdir } from "node:os";
6
9
  import { parseArgs } from "node:util";
7
- import chokidar from "chokidar";
8
- import ignore from "ignore";
9
10
  import { lookup } from "mrmime";
10
11
 
11
12
  const MAX_BODY_BYTES = 1024 * 1024;
12
13
  const GRACE_SECONDS = 10 * 60;
13
14
  const CONNECTION_TIMEOUT = 10_000;
15
+ const CONTROL_TIMEOUT = 35_000;
14
16
  const MAX_RETRY_DELAY = 5_000;
17
+ const CONTROL_PREFIX = "letmeknow-control-";
18
+ const SNAPSHOT_PREFIX = "letmeknow-snapshot-";
19
+ const CONTROL_URL = "https://letmeknow.dev";
15
20
  const credentialPattern = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
16
- const ig = ignore().add([".env", ".env.*", ".git", "*.key", "*.pem", "*.p12", "*.sqlite", "*.db"]);
17
- const privateFilePattern = /\.(?:key|pem|p12|sqlite|db)$/i;
21
+ const privateNames = new Set([".env", ".git", ".ssh", "id_rsa", "id_ed25519", "id_ecdsa", "id_dsa"]);
22
+ const privateFilePattern = /^\.env\.|\.(?:key|pem|p12|ppk|p8|sqlite|sqlite3|db|db3)$|-(?:wal|shm|journal)$/i;
18
23
 
19
24
  function getMimeType(filename) {
20
25
  const type = lookup(filename);
@@ -23,92 +28,6 @@ function getMimeType(filename) {
23
28
  ? `${type}; charset=utf-8`
24
29
  : type;
25
30
  }
26
- const client = String.raw`
27
- const sessionMatch=location.pathname.match(/^\/s\/[a-f0-9]{20}(?:\/|$)/);
28
- const sessionBase=sessionMatch?(sessionMatch[0].endsWith("/")?sessionMatch[0]:sessionMatch[0]+"/"):"/";
29
- const credentialKey="letmeknow-credential:"+location.origin+sessionBase;
30
- const snapshotKey=()=>credentialKey+":state:"+pageIdentity();
31
- let credential;
32
- try{credential=sessionStorage.getItem(credentialKey)}catch{}
33
- let socket;
34
- let retryTimer;
35
- let updateTimer;
36
- let reloadTimer;
37
- let terminal=false;
38
- const controls=()=>[...document.querySelectorAll("button,input,select,textarea")];
39
- const details=()=>[...document.querySelectorAll("details")];
40
- const uniqueId=(element,all)=>element.id&&all.filter(candidate=>candidate.id===element.id).length===1?element.id:null;
41
- const formIdentity=form=>form?.id||form?.getAttribute("name")||form?.getAttribute("action")||"document";
42
- const controlKey=(control,index,all=controls())=>{const id=uniqueId(control,all);if(id)return"id:"+id;const form=control.form;const identity=formIdentity(form)+":"+(control.type||control.localName)+":"+(control.name||"");const occurrence=all.slice(0,index).filter(candidate=>!uniqueId(candidate,all)&&formIdentity(candidate.form)+":"+(candidate.type||candidate.localName)+":"+(candidate.name||"")===identity).length;return"control:"+identity+":"+occurrence};
43
- const detailKey=(element,index,all=details())=>{const id=uniqueId(element,all);return id?"id:"+id:"detail:"+index};
44
- const pageIdentity=()=>location.pathname+location.search+location.hash;
45
- const snapshot=()=>{const all=controls();return{version:1,page:pageIdentity(),controls:all.map((control,index)=>({key:controlKey(control,index,all),value:control.value,checked:control.checked,indeterminate:control.indeterminate,selected:control instanceof HTMLSelectElement?[...control.options].map((option,optionIndex,options)=>option.selected?[option.value,options.slice(0,optionIndex).filter(candidate=>candidate.value===option.value).length]:null).filter(Boolean):undefined,start:typeof control.selectionStart==="number"?control.selectionStart:undefined,end:typeof control.selectionEnd==="number"?control.selectionEnd:undefined,direction:control.selectionDirection||undefined})),active:document.activeElement instanceof Element?controlKey(document.activeElement,all.indexOf(document.activeElement),all):undefined,details:details().map((element,index)=>({key:detailKey(element,index),open:element.open})),x:scrollX,y:scrollY}};
46
- const status=message=>{const element=document.querySelector("[data-letmeknow-status]");if(element)element.textContent=message};
47
- const stripSessionPath=path=>{if(sessionBase==="/")return path;if(path===sessionBase.slice(0,-1))return "/";return path.startsWith(sessionBase)?"/"+path.slice(sessionBase.length):path};
48
- const routePath=()=>{let path=stripSessionPath(location.pathname);return path.endsWith("/")?path+"index.html":path};
49
- const pagePath=path=>{path=path.split("?",1)[0];return stripSessionPath(path)||"/"};
50
- const save=()=>{try{sessionStorage.setItem(snapshotKey(),JSON.stringify(snapshot()))}catch{}};
51
- const restore=()=>{let raw;try{raw=sessionStorage.getItem(snapshotKey())}catch{return}if(!raw)return;let saved;try{saved=JSON.parse(raw)}catch{return}if(saved.version!==1||saved.page!==pageIdentity())return;const all=controls();const savedControls=new Map((Array.isArray(saved.controls)?saved.controls:[]).map(state=>[state.key,state]));let active;for(const [index,control] of all.entries()){const state=savedControls.get(controlKey(control,index,all));if(!state)continue;if(control instanceof HTMLSelectElement&&Array.isArray(state.selected)){const selectedIndexes=new Set(state.selected.filter(Number.isInteger));const selectedValues=new Set(state.selected.filter(Array.isArray).map(entry=>entry.join("\u0000")));for(const [optionIndex,option] of [...control.options].entries()){const occurrence=[...control.options].slice(0,optionIndex).filter(candidate=>candidate.value===option.value).length;option.selected=selectedIndexes.has(optionIndex)||selectedValues.has([option.value,occurrence].join("\u0000"))}}else if(control.type==="checkbox"||control.type==="radio"){control.checked=state.checked;control.indeterminate=state.indeterminate}else{control.value=state.value;if(typeof state.start==="number"&&typeof control.setSelectionRange==="function")control.setSelectionRange(state.start,state.end,state.direction||"none")}if(controlKey(control,index,all)===saved.active)active=control}const savedDetails=new Map((Array.isArray(saved.details)?saved.details:[]).map(state=>[state.key,state]));for(const [index,element] of details().entries()){const state=savedDetails.get(detailKey(element,index));if(state)element.open=state.open}active?.focus({preventScroll:true});scrollTo(saved.x||0,saved.y||0)};
52
- const reload=()=>{if(reloadTimer)return;reloadTimer=setTimeout(()=>{save();location.reload()},75)};
53
- const linkedStylesheet=path=>{for(const link of document.querySelectorAll('link[rel~="stylesheet"]')){let url;try{url=new URL(link.href,location.href)}catch{continue}if(url.origin!==location.origin)continue;if(sessionBase!=="/"&&!url.pathname.startsWith(sessionBase))continue;if(pagePath(url.pathname)===path)return link}return null};
54
- const refreshStylesheet=link=>{const url=new URL(link.href,location.href);url.searchParams.set("_letmeknow",crypto.randomUUID());link.href=url.href};
55
- const flushUpdates=()=>{updateTimer=undefined;const paths=[...pendingUpdates];pendingUpdates.clear();let shouldReload=false;const styles=[];for(const path of paths){if(/\.html?$/i.test(path)){if(path===routePath())shouldReload=true}else if(/\.css$/i.test(path)){const link=linkedStylesheet(path);if(link)styles.push([path,link]);else shouldReload=true}else shouldReload=true}if(shouldReload){reload();return}for(const [,link] of styles)refreshStylesheet(link)};
56
- const pendingUpdates=new Set();
57
- const update=path=>{if(typeof path!=="string")return;path=pagePath(path);pendingUpdates.add(path);if(!updateTimer)updateTimer=setTimeout(flushUpdates,75)};
58
- let saveTimer;
59
- const scheduleSave=()=>{if(!saveTimer)saveTimer=setTimeout(()=>{saveTimer=undefined;save()},100)};
60
- addEventListener("input",scheduleSave,true);
61
- addEventListener("change",scheduleSave,true);
62
- addEventListener("toggle",scheduleSave,true);
63
- addEventListener("focusin",scheduleSave,true);
64
- addEventListener("selectionchange",scheduleSave,true);
65
- addEventListener("scroll",scheduleSave,{passive:true});
66
- addEventListener("pagehide",save);
67
- addEventListener("load",()=>requestAnimationFrame(restore),{once:true});
68
- document.addEventListener("reset",()=>setTimeout(save));
69
- const connect=()=>{
70
- clearTimeout(retryTimer);retryTimer=undefined;
71
- const url=new URL(sessionBase+"_letmeknow/client",location.href);url.protocol=url.protocol==="https:"?"wss:":"ws:";
72
- const current=credential?new WebSocket(url,credential):new WebSocket(url);
73
- socket=current;
74
- current.onmessage=event=>{
75
- if(socket!==current||typeof event.data!=="string")return;
76
- let message;try{message=JSON.parse(event.data)}catch{return}
77
- if(message.type==="credential"){credential=message.credential;try{sessionStorage.setItem(credentialKey,credential)}catch{}return}
78
- if(message.type==="challenge"){if(current.readyState===WebSocket.OPEN)try{current.send(JSON.stringify({type:"alive",nonce:message.nonce}))}catch{}return}
79
- if(message.type==="busy"){status("This session is open elsewhere");retryTimer=setTimeout(connect,message.retry_after*1000);return}
80
- if(message.type==="file_update"){update(message.path);return}
81
- if(message.type==="closed"){terminal=true;try{sessionStorage.removeItem(credentialKey)}catch{}status(message.message)}
82
- };
83
- current.onclose=()=>{if(socket!==current||terminal)return;if(!retryTimer){status("Reconnecting…");retryTimer=setTimeout(connect,1000)}};
84
- current.onerror=()=>{};
85
- };
86
- document.addEventListener("submit",async event=>{
87
- const form=event.target;
88
- if(!(form instanceof HTMLFormElement))return;
89
- event.preventDefault();
90
- const submitter=event.submitter;
91
- const method=(submitter?.getAttribute("formmethod")??form.getAttribute("method")??"get").toLowerCase();
92
- if(method!=="get"&&method!=="post"){status("Only GET and POST forms are supported");return}
93
- if(!form.checkValidity()){form.reportValidity();return}
94
- let target;
95
- try{const action=submitter?.getAttribute("formaction")??form.getAttribute("action")??location.href;const base=sessionBase!=="/"&&location.pathname===sessionBase.slice(0,-1)?new URL(sessionBase,location.href):location.href;target=new URL(action,base)}catch{status("Invalid form action");return}
96
- if(target.origin!==location.origin){status("Form actions must stay on this site");return}
97
- if(sessionBase!=="/"){
98
- const sessionPath=sessionBase.slice(0,-1);
99
- if(target.pathname===sessionPath)target.pathname=sessionBase;
100
- else if(!target.pathname.startsWith(sessionBase))target.pathname=sessionBase+target.pathname.replace(/^\//,"");
101
- }
102
- const values=new URLSearchParams();
103
- for(const [name,value] of new FormData(form,submitter)){if(typeof value!=="string"){status("File inputs are not supported");return}values.append(name,value)}
104
- const actionPath=sessionBase!=="/"?target.pathname.slice(sessionBase.length-1)||"/":target.pathname;
105
- const metadata={id:crypto.randomUUID(),form_id:form.id||null,action:actionPath+target.search,trigger:{id:submitter?.id||null,name:submitter?.getAttribute("name"),value:submitter?.getAttribute("value")}};
106
- const headers={"X-LetMeKnow-Submission":"1","X-LetMeKnow-ID":encodeURIComponent(metadata.id),"X-LetMeKnow-Form-ID":encodeURIComponent(metadata.form_id??""),"X-LetMeKnow-Action":encodeURIComponent(metadata.action),"X-LetMeKnow-Trigger-ID":encodeURIComponent(metadata.trigger.id??""),"X-LetMeKnow-Trigger-Name":encodeURIComponent(metadata.trigger.name??""),"X-LetMeKnow-Trigger-Value":encodeURIComponent(metadata.trigger.value??"")};
107
- if(method==="get")for(const [name,value] of values)target.searchParams.append(name,value);
108
- try{const response=await fetch(target,{method:method.toUpperCase(),headers,...(method==="post"?{body:values}:{})});if(!response.ok)throw new Error();status("Submitted")}catch{status("The submission failed")}
109
- });
110
- connect();
111
- `;
112
31
 
113
32
  function header(packet, name) {
114
33
  const entry = Object.entries(packet.headers || {}).find(([key]) => key.toLowerCase() === name);
@@ -139,8 +58,7 @@ function errorResponse(packet, status, message) {
139
58
  }
140
59
 
141
60
  function deniedPath(pathname) {
142
- const normalized = pathname.replace(/^\/+/, "");
143
- return normalized !== "" && (ig.ignores(normalized) || pathname.split("/").filter(Boolean).some(part => ig.ignores(part) || privateFilePattern.test(part)));
61
+ return pathname.split("/").filter(Boolean).some(part => privateNames.has(part) || privateFilePattern.test(part));
144
62
  }
145
63
 
146
64
  function inside(root, target) {
@@ -175,14 +93,8 @@ function requestUrl(packet) {
175
93
  return { pathname, encodedPathname: url.pathname, search: url.search };
176
94
  }
177
95
 
178
- function htmlWithClient(body) {
179
- const text = body.toString("utf8");
180
- const script = `<script type="module" data-letmeknow-client>${client}</script>`;
181
- const closingBody = text.search(/<\/body\s*>/i);
182
- return Buffer.from(closingBody < 0 ? text + script : text.slice(0, closingBody) + script + text.slice(closingBody), "utf8");
183
- }
184
-
185
- 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 });
186
98
  const method = typeof packet.method === "string" ? packet.method.toUpperCase() : "";
187
99
  if (method !== "GET" && method !== "HEAD") return errorResponse(packet, 405, "method not allowed");
188
100
  let request;
@@ -202,9 +114,9 @@ async function staticResponse(root, packet) {
202
114
  return errorResponse(packet, 500, "preview request failed");
203
115
  }
204
116
  if (info.isDirectory()) {
205
- if (!request.pathname.endsWith("/")) {
206
- const location = request.encodedPathname + "/" + request.search;
207
- return response(packet, 301, Buffer.from(`Redirecting to ${location}`), { Location: location, "Content-Type": "text/plain; charset=utf-8" });
117
+ if (!request.encodedPathname.endsWith("/")) {
118
+ const location = request.encodedPathname.slice(request.encodedPathname.lastIndexOf("/") + 1) + "/" + request.search;
119
+ return published(301, Buffer.from(`Redirecting to ${location}`), { Location: location, "Content-Type": "text/plain; charset=utf-8" });
208
120
  }
209
121
  const index = resolve(target, "index.html");
210
122
  try { target = await realpath(index); } catch (cause) {
@@ -213,95 +125,204 @@ async function staticResponse(root, packet) {
213
125
  }
214
126
  if (!inside(root, target)) return errorResponse(packet, 403, "forbidden");
215
127
  if (deniedPath("/" + relative(root, target).split(sep).join("/"))) return errorResponse(packet, 403, "forbidden");
128
+ try { info = await stat(target); } catch (cause) {
129
+ if (cause?.code === "ENOENT" || cause?.code === "ENOTDIR") return errorResponse(packet, 404, "not found");
130
+ if (cause?.code === "EACCES" || cause?.code === "EPERM") return errorResponse(packet, 403, "forbidden");
131
+ return errorResponse(packet, 500, "preview request failed");
132
+ }
216
133
  } else if (request.pathname.endsWith("/")) return errorResponse(packet, 404, "not found");
217
- let body;
218
- try { body = await readFile(target); } catch (cause) {
134
+ let file;
135
+ try { file = await open(target, constants.O_RDONLY | constants.O_NOFOLLOW); } catch (cause) {
219
136
  if (cause?.code === "ENOENT" || cause?.code === "ENOTDIR") return errorResponse(packet, 404, "not found");
220
- if (cause?.code === "EACCES" || cause?.code === "EPERM") return errorResponse(packet, 403, "forbidden");
137
+ if (cause?.code === "EACCES" || cause?.code === "EPERM" || cause?.code === "ELOOP") return errorResponse(packet, 403, "forbidden");
138
+ return errorResponse(packet, 500, "preview request failed");
139
+ }
140
+ try {
141
+ info = await file.stat();
142
+ if (!info.isFile()) return errorResponse(packet, 404, "not found");
143
+ if (info.size > MAX_BODY_BYTES) return errorResponse(packet, 413, "response body is too large");
144
+ const body = await file.readFile();
145
+ if (body.byteLength > MAX_BODY_BYTES) return errorResponse(packet, 413, "response body is too large");
146
+ return published(200, body, { "Content-Type": getMimeType(target) });
147
+ } catch {
221
148
  return errorResponse(packet, 500, "preview request failed");
149
+ } finally {
150
+ await file.close();
222
151
  }
223
- if (body.byteLength > MAX_BODY_BYTES) return errorResponse(packet, 413, "response body is too large");
224
- if (extname(target).toLowerCase() === ".html") body = htmlWithClient(body);
225
- if (body.byteLength > MAX_BODY_BYTES) return errorResponse(packet, 413, "response body is too large");
226
- return response(packet, 200, body, { "Content-Type": getMimeType(target) });
227
152
  }
228
153
 
229
- async function submission(packet) {
154
+ async function multipartSubmission(body, contentType, getAttachmentInbox) {
155
+ const formData = await new Request("http://letmeknow.local", {
156
+ method: "POST",
157
+ headers: { "Content-Type": contentType },
158
+ body
159
+ }).formData();
160
+ const values = Object.create(null);
161
+ const attachments = [];
162
+ for (const [name, value] of formData) {
163
+ if (typeof value === "string") {
164
+ addValue(values, name, value);
165
+ continue;
166
+ }
167
+ if (value.name === "") continue;
168
+ const bytes = Buffer.from(await value.arrayBuffer());
169
+ const path = join(await getAttachmentInbox(), randomUUID());
170
+ await writeFile(path, bytes, { mode: 0o600, flag: "wx" });
171
+ attachments.push({ field: name, name: value.name, type: value.type, size: bytes.byteLength, path });
172
+ }
173
+ return { values, attachments };
174
+ }
175
+
176
+ async function submission(packet, getAttachmentInbox, recordInteraction) {
230
177
  const url = requestUrl(packet);
231
178
  const method = typeof packet.method === "string" ? packet.method.toUpperCase() : "";
232
179
  const values = Object.create(null);
180
+ let attachments;
233
181
  if (method === "GET") {
234
182
  for (const [name, value] of new URLSearchParams(url.search)) addValue(values, name, value);
235
183
  } else if (method === "POST") {
236
184
  const body = Buffer.from(typeof packet.body === "string" ? packet.body : "", "base64");
237
185
  if (body.byteLength > MAX_BODY_BYTES) throw new Error("submission is too large");
238
- const contentType = header(packet, "content-type")?.split(";", 1)[0].trim().toLowerCase();
239
- if (contentType !== "application/x-www-form-urlencoded") throw new Error("unsupported submission encoding");
240
- for (const [name, value] of new URLSearchParams(body.toString("utf8"))) addValue(values, name, value);
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");
241
195
  } else throw new Error("unsupported submission method");
242
196
  const event = {
243
197
  type: "submit",
244
- id: encodedHeader(packet, "x-letmeknow-id"),
198
+ id: encodedHeader(packet, "x-letmeknow-id") || randomUUID(),
245
199
  method,
246
200
  action: encodedHeader(packet, "x-letmeknow-action") || url.pathname,
247
201
  form_id: encodedHeader(packet, "x-letmeknow-form-id"),
248
202
  trigger: { id: encodedHeader(packet, "x-letmeknow-trigger-id"), name: encodedHeader(packet, "x-letmeknow-trigger-name"), value: encodedHeader(packet, "x-letmeknow-trigger-value") },
249
203
  values
250
204
  };
251
- process.stdout.write(`${JSON.stringify(event)}\n`);
252
- return response(packet, 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);
209
+ return response(packet, 202);
253
210
  }
254
211
 
255
- async function handleRequest(root, packet) {
212
+ async function handleRequest(root, workspaceId, packet, getAttachmentInbox, recordInteraction) {
256
213
  if (header(packet, "x-letmeknow-submission") === "1") {
257
- try { return await submission(packet); } 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"); }
258
215
  }
259
- return staticResponse(root, packet);
216
+ return staticResponse(root, packet, workspaceId);
260
217
  }
261
218
 
262
219
  function options(directory) {
263
- const root = resolve(directory || process.cwd());
220
+ const root = resolve(directory);
264
221
  if (!existsSync(root) || !statSync(root).isDirectory()) throw new Error(`directory does not exist: ${root}`);
265
222
  return realpath(root).then(root => ({ root }));
266
223
  }
267
224
 
268
- function endpoint(control, credential, sessionUrl) {
269
- const url = new URL(control);
270
- url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
271
- url.pathname = "/v1/connect";
272
- url.search = "";
273
- url.hash = "";
274
- if (credential && sessionUrl) {
275
- const publicUrl = new URL(sessionUrl);
276
- const hostCode = publicUrl.hostname.match(/^([a-f0-9]{20})\.letmeknow\.dev$/);
277
- const pathCode = publicUrl.pathname.match(/^\/s\/([a-f0-9]{20})(?:\/|$)/);
278
- const code = hostCode?.[1] || pathCode?.[1];
279
- if (code) 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);
280
250
  }
281
- return url;
282
251
  }
283
252
 
284
- function validSessionUrl(value) {
285
- if (typeof value !== "string") return false;
286
- let url;
287
- try { url = new URL(value); } catch { return false; }
288
- if (url.protocol !== "http:" && url.protocol !== "https:") return false;
289
- if (/^[a-f0-9]{20}\.letmeknow\.dev$/.test(url.hostname)) return true;
290
- return /^\/s\/[a-f0-9]{20}(?:\/|$)/.test(url.pathname);
253
+ async function snapshotDirectory(root) {
254
+ const snapshot = await mkdtemp(join(tmpdir(), SNAPSHOT_PREFIX));
255
+ try {
256
+ await copyDirectory(root, snapshot, root);
257
+ return snapshot;
258
+ } catch (cause) {
259
+ await rm(snapshot, { recursive: true, force: true });
260
+ throw cause;
261
+ }
262
+ }
263
+
264
+ function mutateQueue() {
265
+ let chain = Promise.resolve();
266
+ return operation => {
267
+ const previous = chain;
268
+ let release;
269
+ chain = new Promise(resolve => { release = resolve; });
270
+ return previous.then(operation).finally(release);
271
+ };
272
+ }
273
+
274
+ function connectControl(root, packet) {
275
+ const timeout = Math.max(CONTROL_TIMEOUT, ((packet.wait_seconds || 0) + 5) * 1_000);
276
+ return new Promise((resolve, reject) => {
277
+ const socket = net.createConnection(controlPath(root));
278
+ let output = "";
279
+ let settled = false;
280
+ const timer = setTimeout(() => {
281
+ socket.destroy();
282
+ reject(new Error("control request timed out"));
283
+ }, timeout);
284
+ const finish = (cause, value) => {
285
+ if (settled) return;
286
+ settled = true;
287
+ clearTimeout(timer);
288
+ if (cause) reject(cause);
289
+ else resolve(value);
290
+ };
291
+ socket.setEncoding("utf8");
292
+ socket.on("connect", () => socket.write(JSON.stringify(packet) + "\n"));
293
+ socket.on("data", chunk => {
294
+ output += chunk;
295
+ const newline = output.indexOf("\n");
296
+ if (newline < 0) return;
297
+ try { finish(null, JSON.parse(output.slice(0, newline))); } catch (cause) { finish(cause); }
298
+ socket.destroy();
299
+ });
300
+ socket.on("error", cause => finish(new Error(`serve is not running: ${cause.message}`)));
301
+ socket.on("close", () => { if (!settled) finish(new Error("serve closed the control connection")); });
302
+ });
291
303
  }
292
304
 
293
305
  async function start(directory) {
294
306
  const { root } = await options(directory);
295
- const control = process.env.LETMEKNOW_URL || "https://letmeknow.dev";
307
+ let attachmentInboxPromise;
308
+ const getAttachmentInbox = () => {
309
+ attachmentInboxPromise ??= mkdtemp(join(tmpdir(), "letmeknow-attachments-"));
310
+ return attachmentInboxPromise;
311
+ };
312
+ const socketPath = controlPath(root);
313
+ let publishedRoot = await snapshotDirectory(root);
314
+ let workspaceId = randomUUID();
315
+ let workspaceSequence = 1;
316
+ const workspaceIds = new Set([workspaceId]);
317
+ const eventLog = [];
318
+ let committedCursor = 0;
319
+ const seenEvents = new Set();
320
+ const tokens = new Map();
321
+ const pendingTokens = new Map();
322
+ const pullWaiters = new Set();
323
+ const mutate = mutateQueue();
324
+ let controlServer;
296
325
  let send = () => false;
297
- const watcher = chokidar.watch(root, { ignoreInitial: true });
298
- watcher.on("all", (_event, filename) => {
299
- if (!filename) { send({ type: "file_update", path: "/" }); return; }
300
- const file = resolve(root, String(filename));
301
- const path = relative(root, file).split(sep).join("/");
302
- if (!path || path.startsWith("../") || path === ".." || deniedPath("/" + path)) return;
303
- send({ type: "file_update", path: "/" + path.split("/").map(encodeURIComponent).join("/") });
304
- });
305
326
  let socket;
306
327
  let credential;
307
328
  let sessionUrl;
@@ -311,13 +332,144 @@ async function start(directory) {
311
332
  let retryUntil = 0;
312
333
  let stopped = false;
313
334
  let ready = false;
335
+ let initialPublished = false;
336
+
337
+ const batch = () => {
338
+ const start = committedCursor;
339
+ const end = eventLog.length;
340
+ const key = `${workspaceId}:${start}:${end}`;
341
+ const existing = pendingTokens.get(key);
342
+ if (existing) return existing;
343
+ const token = randomUUID();
344
+ const events = eventLog.slice(start, end).map(event => ({
345
+ ...event,
346
+ context: {
347
+ based_on: event.based_on ?? null,
348
+ current: workspaceId,
349
+ relationship: event.based_on === workspaceId ? "current" : workspaceIds.has(event.based_on) ? "stale" : "unknown"
350
+ }
351
+ }));
352
+ tokens.set(token, { start, end, parent: workspaceId, status: "pending", key });
353
+ const result = { ok: true, type: "batch", token, workspace: workspaceId, workspace_sequence: workspaceSequence, frontier: end, events };
354
+ pendingTokens.set(key, result);
355
+ return result;
356
+ };
357
+
358
+ const notifyPullWaiters = () => {
359
+ for (const waiter of [...pullWaiters]) {
360
+ if (eventLog.length === committedCursor) continue;
361
+ pullWaiters.delete(waiter);
362
+ clearTimeout(waiter.timer);
363
+ waiter.resolve(batch());
364
+ }
365
+ };
366
+
367
+ const pull = waitSeconds => {
368
+ if (eventLog.length > committedCursor || waitSeconds <= 0) return Promise.resolve(batch());
369
+ return new Promise(resolve => {
370
+ const waiter = { resolve, timer: setTimeout(() => { pullWaiters.delete(waiter); resolve(batch()); }, waitSeconds * 1_000) };
371
+ pullWaiters.add(waiter);
372
+ });
373
+ };
374
+
375
+ const commit = async (token, publish) => {
376
+ const record = tokens.get(token);
377
+ if (!record) return { ok: false, error: "unknown batch token" };
378
+ if (record.status !== "pending") return record.result;
379
+ if (record.start < committedCursor && record.end <= committedCursor) {
380
+ pendingTokens.delete(record.key);
381
+ record.status = "committed";
382
+ record.result = { ok: true, type: "already_committed", token, workspace: workspaceId, workspace_sequence: workspaceSequence, frontier: committedCursor };
383
+ return record.result;
384
+ }
385
+ if (record.parent !== workspaceId || record.start !== committedCursor) {
386
+ pendingTokens.delete(record.key);
387
+ record.status = "failed";
388
+ record.result = { ok: false, error: "batch is based on an old workspace or cursor", current_workspace: workspaceId, frontier: committedCursor };
389
+ return record.result;
390
+ }
391
+ if (publish) {
392
+ const nextRoot = await snapshotDirectory(root);
393
+ const previousRoot = publishedRoot;
394
+ publishedRoot = nextRoot;
395
+ workspaceId = randomUUID();
396
+ workspaceIds.add(workspaceId);
397
+ workspaceSequence += 1;
398
+ record.result = { ok: true, type: "published", token, workspace: workspaceId, workspace_sequence: workspaceSequence, parent: record.parent, frontier: record.end, events: eventLog.slice(record.start, record.end).map(event => event.id) };
399
+ void rm(previousRoot, { recursive: true, force: true }).catch(() => {});
400
+ } else {
401
+ record.result = { ok: true, type: "acknowledged", token, workspace: workspaceId, workspace_sequence: workspaceSequence, frontier: record.end, events: eventLog.slice(record.start, record.end).map(event => event.id) };
402
+ }
403
+ committedCursor = record.end;
404
+ pendingTokens.delete(record.key);
405
+ record.status = "committed";
406
+ if (publish) send({ type: "revision" });
407
+ return record.result;
408
+ };
409
+
410
+ const dispatchControl = async request => {
411
+ if (!request || typeof request !== "object") return { ok: false, error: "invalid control request" };
412
+ if (request.type === "pull") return pull(Number.isFinite(request.wait_seconds) ? Math.max(0, request.wait_seconds) : 0);
413
+ if (request.type === "push") return commit(typeof request.token === "string" ? request.token : "", true);
414
+ if (request.type === "ack") return commit(typeof request.token === "string" ? request.token : "", false);
415
+ return { ok: false, error: "unknown control request" };
416
+ };
417
+
418
+ const controlConnections = new Set();
419
+ controlServer = net.createServer(connection => {
420
+ controlConnections.add(connection);
421
+ connection.setEncoding("utf8");
422
+ let input = "";
423
+ let handled = false;
424
+ connection.on("data", async chunk => {
425
+ input += chunk;
426
+ if (input.length > MAX_BODY_BYTES || handled) return;
427
+ const newline = input.indexOf("\n");
428
+ if (newline < 0) return;
429
+ handled = true;
430
+ let result;
431
+ try {
432
+ const request = JSON.parse(input.slice(0, newline));
433
+ result = request.type === "pull" ? await dispatchControl(request) : await mutate(() => dispatchControl(request));
434
+ } catch (cause) { result = { ok: false, error: cause instanceof Error ? cause.message : "control request failed" }; }
435
+ connection.end(JSON.stringify(result) + "\n");
436
+ });
437
+ connection.on("close", () => controlConnections.delete(connection));
438
+ connection.on("error", () => controlConnections.delete(connection));
439
+ });
440
+ await new Promise((resolveListen, reject) => {
441
+ controlServer.once("error", reject);
442
+ controlServer.listen(socketPath, async () => {
443
+ try { await chmod(socketPath, 0o600); } catch (cause) { controlServer.close(() => reject(cause)); return; }
444
+ controlServer.off("error", reject);
445
+ resolveListen();
446
+ });
447
+ }).catch(async cause => {
448
+ await rm(publishedRoot, { recursive: true, force: true });
449
+ throw new Error(`cannot start local control channel: ${cause.message}`);
450
+ });
451
+
452
+ const recordInteraction = event => mutate(async () => {
453
+ if (seenEvents.has(event.id)) return;
454
+ seenEvents.add(event.id);
455
+ eventLog.push(event);
456
+ notifyPullWaiters();
457
+ });
458
+
314
459
  const stop = async code => {
315
460
  if (stopped) return;
316
461
  stopped = true;
317
462
  clearTimeout(retryTimer);
318
463
  clearTimeout(connectionTimer);
464
+ send({ type: "close" });
319
465
  try { socket?.close(); } catch {}
320
- await watcher.close();
466
+ for (const connection of controlConnections) connection.destroy();
467
+ await new Promise(resolveClose => controlServer.close(() => resolveClose()));
468
+ await unlink(socketPath).catch(() => {});
469
+ await rm(publishedRoot, { recursive: true, force: true });
470
+ if (attachmentInboxPromise) {
471
+ try { await rm(await attachmentInboxPromise, { recursive: true, force: true }); } catch {}
472
+ }
321
473
  process.exit(code);
322
474
  };
323
475
  process.once("SIGINT", () => void stop(0));
@@ -332,11 +484,11 @@ async function start(directory) {
332
484
  const connect = () => {
333
485
  if (stopped) return;
334
486
  const reconnecting = Boolean(credential && sessionUrl);
335
- const current = socket = reconnecting ? new WebSocket(endpoint(control, credential, sessionUrl), credential) : new WebSocket(endpoint(control));
487
+ const current = socket = reconnecting ? new WebSocket(endpoint(credential, sessionUrl), credential) : new WebSocket(endpoint());
336
488
  connectionTimer = setTimeout(() => {
337
489
  if (socket !== current || current.readyState === WebSocket.OPEN || stopped) return;
338
490
  try { current.close(); } catch {}
339
- if (reconnecting) retry(); else void stop(1);
491
+ if (!reconnecting) void stop(1);
340
492
  }, CONNECTION_TIMEOUT);
341
493
  current.addEventListener("open", () => {
342
494
  if (socket !== current || stopped) return;
@@ -359,9 +511,15 @@ async function start(directory) {
359
511
  } else if (packet.type === "session") {
360
512
  if (!validSessionUrl(packet.url)) return void stop(1);
361
513
  sessionUrl = packet.url;
362
- if (!ready) { ready = true; process.stdout.write(`${JSON.stringify({ type: "ready", url: sessionUrl })}\n`); }
514
+ if (!initialPublished) {
515
+ initialPublished = true;
516
+ send({ type: "revision" });
517
+ }
518
+ if (!ready) { ready = true; process.stdout.write(`${JSON.stringify({ type: "ready", url: sessionUrl, workspace: workspaceId, workspace_sequence: workspaceSequence })}\n`); }
363
519
  } else if (packet.type === "http_request") {
364
- void handleRequest(root, packet).then(result => send(result)).catch(() => send(errorResponse(packet, 500, "preview request failed")));
520
+ const requestRoot = publishedRoot;
521
+ const requestWorkspace = workspaceId;
522
+ void handleRequest(requestRoot, requestWorkspace, packet, getAttachmentInbox, recordInteraction).then(result => send(result)).catch(() => send(errorResponse(packet, 500, "preview request failed")));
365
523
  } else if (packet.type === "closed") {
366
524
  void stop(0);
367
525
  } else if (packet.type === "error") {
@@ -384,29 +542,79 @@ async function start(directory) {
384
542
  await new Promise(() => {});
385
543
  }
386
544
 
387
- let parsed;
388
- try {
389
- parsed = parseArgs({
390
- args: process.argv.slice(2),
391
- options: {
392
- skill: { type: "boolean" },
393
- help: { type: "boolean", short: "h" }
394
- },
395
- allowPositionals: true
396
- });
397
- } catch (cause) {
398
- process.stderr.write(`letmeknow: ${cause instanceof Error ? cause.message : "invalid arguments"}\n`);
399
- process.exit(1);
545
+ function endpoint(credential, sessionUrl) {
546
+ const url = new URL(CONTROL_URL);
547
+ url.protocol = "wss:";
548
+ url.pathname = "/v2/connect";
549
+ if (credential && sessionUrl) {
550
+ const publicUrl = new URL(sessionUrl);
551
+ const code = publicUrl.hostname.match(/^([a-f0-9]{20})\.letmeknow\.dev$/)?.[1];
552
+ if (!code) throw new Error("invalid session URL");
553
+ url.searchParams.set("code", code);
554
+ }
555
+ return url;
556
+ }
557
+
558
+ function validSessionUrl(value) {
559
+ if (typeof value !== "string") return false;
560
+ let url;
561
+ try { url = new URL(value); } catch { return false; }
562
+ return url.protocol === "https:" && /^[a-f0-9]{20}\.letmeknow\.dev$/.test(url.hostname) && url.pathname === "/" && !url.search && !url.hash;
563
+ }
564
+
565
+ function usage() {
566
+ return "Usage:\n npx letmeknow-cli serve <directory>\n npx letmeknow-cli pull <directory> [--wait <seconds>]\n npx letmeknow-cli push <directory> --based-on <token>\n npx letmeknow-cli ack <directory> --based-on <token>\n";
400
567
  }
401
568
 
402
- if (parsed.values.skill) {
403
- if (parsed.positionals.length > 0) { process.stderr.write("Usage: npx letmeknow-cli --skill\n"); process.exit(1); }
404
- writeSync(1, readFileSync(new URL("../SKILL.md", import.meta.url)));
405
- } else if (parsed.values.help) {
406
- 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");
407
- } else if (parsed.positionals.length > 1) {
408
- process.stderr.write("letmeknow: only one directory may be provided\n");
409
- process.exit(1);
410
- } else {
411
- try { await start(parsed.positionals[0]); } catch (cause) { process.stderr.write(`letmeknow: ${cause instanceof Error ? cause.message : "server failed"}\n`); process.exitCode = 1; }
569
+ function commandArgs() {
570
+ let parsed;
571
+ try {
572
+ parsed = parseArgs({
573
+ args: process.argv.slice(2),
574
+ options: {
575
+ skill: { type: "boolean" },
576
+ help: { type: "boolean", short: "h" },
577
+ wait: { type: "string" },
578
+ "based-on": { type: "string" }
579
+ },
580
+ allowPositionals: true,
581
+ strict: true
582
+ });
583
+ } catch (cause) {
584
+ throw new Error(cause instanceof Error ? cause.message : "invalid arguments");
585
+ }
586
+ if (parsed.values.skill || parsed.values.help) {
587
+ if (parsed.positionals.length || parsed.values.wait !== undefined || parsed.values["based-on"] !== undefined) throw new Error(usage());
588
+ return { command: parsed.values.skill ? "skill" : "help" };
589
+ }
590
+ const [command, directory, ...extra] = parsed.positionals;
591
+ if (!command || !directory || extra.length) throw new Error(usage());
592
+ if (command === "serve" && (parsed.values.wait !== undefined || parsed.values["based-on"] !== undefined)) throw new Error(usage());
593
+ if (command === "pull" && parsed.values["based-on"] !== undefined) throw new Error(usage());
594
+ if ((command === "push" || command === "ack") && parsed.values.wait !== undefined) throw new Error(usage());
595
+ if (!["serve", "pull", "push", "ack"].includes(command)) throw new Error(usage());
596
+ let wait = 0;
597
+ if (parsed.values.wait !== undefined) {
598
+ wait = Number(parsed.values.wait);
599
+ if (!Number.isFinite(wait) || wait < 0) throw new Error("--wait must be a non-negative number");
600
+ }
601
+ if ((command === "push" || command === "ack") && typeof parsed.values["based-on"] !== "string") throw new Error("--based-on is required");
602
+ return { command, directory, wait, token: parsed.values["based-on"] };
603
+ }
604
+
605
+ let command;
606
+ try {
607
+ command = commandArgs();
608
+ if (command.command === "skill") writeSync(1, readFileSync(new URL("../SKILL.md", import.meta.url)));
609
+ else if (command.command === "help") process.stdout.write(usage());
610
+ else if (command.command === "serve") await start(command.directory);
611
+ else {
612
+ const { root } = await options(command.directory);
613
+ const result = await connectControl(root, command.command === "pull" ? { type: "pull", wait_seconds: command.wait } : { type: command.command, token: command.token });
614
+ process.stdout.write(`${JSON.stringify(result)}\n`);
615
+ if (!result.ok) process.exitCode = 1;
616
+ }
617
+ } catch (cause) {
618
+ process.stderr.write(`letmeknow: ${cause instanceof Error ? cause.message : "command failed"}\n`);
619
+ process.exitCode = 1;
412
620
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "letmeknow-cli",
3
- "version": "0.4.11",
3
+ "version": "0.6.0",
4
4
  "description": "A live static preview with agent-readable form submissions.",
5
5
  "files": [
6
6
  "bin",
@@ -17,7 +17,8 @@
17
17
  "dev": "wrangler dev",
18
18
  "deploy": "wrangler deploy",
19
19
  "check": "tsc --noEmit && tsc -p tsconfig.test.json --noEmit",
20
- "test": "npm run check && vitest run && node --test tests/cli.test.js tests/integration.test.js"
20
+ "test": "npm run check && vitest run && node --test tests/cli.test.js",
21
+ "test:browser": "node --test tests/browser.test.js"
21
22
  },
22
23
  "devDependencies": {
23
24
  "@cloudflare/vitest-plugin": "^1.1.0",
@@ -29,8 +30,6 @@
29
30
  "ws": "^8.21.3"
30
31
  },
31
32
  "dependencies": {
32
- "chokidar": "^5.0.0",
33
- "ignore": "^7.0.6",
34
33
  "mrmime": "^2.0.1"
35
34
  }
36
35
  }