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 +59 -25
- package/SKILL.md +51 -35
- package/bin/letmeknow.js +317 -77
- package/package.json +1 -2
package/README.md
CHANGED
|
@@ -1,64 +1,98 @@
|
|
|
1
1
|
# LetMeKnow
|
|
2
2
|
|
|
3
|
-
LetMeKnow gives an agent a temporary public
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
21
|
+
## Publish revisions
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
`serve` snapshots the initial folder. Later filesystem changes remain private until explicitly published:
|
|
24
24
|
|
|
25
|
-
|
|
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
|
-
|
|
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
|
-
|
|
65
|
+
## Workspace behavior
|
|
30
66
|
|
|
31
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
##
|
|
26
|
+
## Agent loop
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
Filesystem writes are private drafts. Explicitly pull feedback and publish coherent revisions:
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
```bash
|
|
31
|
+
npx letmeknow-cli pull ./preview --wait 30
|
|
32
|
+
# validate feedback and edit files
|
|
33
|
+
npx letmeknow-cli push ./preview --based-on <batch-token>
|
|
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
|
-
|
|
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
|
-
|
|
46
|
+
A push may publish independent work from an empty batch. Use the token returned by an empty `pull`.
|
|
35
47
|
|
|
36
|
-
|
|
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="
|
|
40
|
-
<label>
|
|
41
|
-
<button name="
|
|
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
|
|
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
|
-
|
|
64
|
+
A pulled event includes the workspace the human saw:
|
|
48
65
|
|
|
49
66
|
```json
|
|
50
|
-
{
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
85
|
+
Escape untrusted text before placing it in HTML.
|
|
70
86
|
|
|
71
87
|
## Stop
|
|
72
88
|
|
|
73
|
-
Send `SIGINT` or `SIGTERM` to
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
221
|
-
const
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
251
|
+
visited.delete(sourceReal);
|
|
231
252
|
}
|
|
232
253
|
|
|
233
|
-
function
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
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 (!
|
|
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
|
-
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
}
|
|
380
|
-
|
|
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.
|
|
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
|
}
|