letmeknow-cli 0.1.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.
- package/README.md +120 -0
- package/SKILL.md +98 -0
- package/bin/letmeknow.js +299 -0
- package/package.json +28 -0
package/README.md
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# LetMeKnow
|
|
2
|
+
|
|
3
|
+
LetMeKnow gives an agent a temporary interactive web surface for a human. It is not a localhost proxy and does not read directories. A Node CLI carries newline-delimited JSON between stdin/stdout and a Cloudflare Durable Object over one persistent WebSocket.
|
|
4
|
+
|
|
5
|
+
## CLI
|
|
6
|
+
|
|
7
|
+
Node 22 or newer is required.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npx letmeknow-cli
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
The deployed service is used by default. Set `LETMEKNOW_URL` for local development:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
LETMEKNOW_URL=http://localhost:8787 npx letmeknow-cli
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
stdin contains one compact JSON command per line. stdout contains one JSON event per line. Diagnostics go to stderr.
|
|
20
|
+
|
|
21
|
+
Print the agent-facing skill file without opening a network connection:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npx letmeknow-cli --skill > SKILL.md
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Start a session:
|
|
28
|
+
|
|
29
|
+
```json
|
|
30
|
+
{"type":"open","id":"1"}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
stdout returns the URL to share with the human:
|
|
34
|
+
|
|
35
|
+
```json
|
|
36
|
+
{"type":"session","id":"1","url":"https://0123456789abcdef0123.letmeknow.dev/","expires_after_disconnect":600}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
There is no initial bundle. Initial resources and later updates are the same `put` command:
|
|
40
|
+
|
|
41
|
+
```json
|
|
42
|
+
{"type":"put","id":"2","path":"/","content_type":"text/html; charset=utf-8","body":"<h1>Hello</h1>"}
|
|
43
|
+
{"type":"put","id":"3","path":"/app.js","content_type":"text/javascript","body":"document.body.append(' ready')"}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Protocol
|
|
47
|
+
|
|
48
|
+
### Commands: stdin to LetMeKnow
|
|
49
|
+
|
|
50
|
+
- `open` creates the session and emits `session`.
|
|
51
|
+
- `put` stores or replaces an exact pathname.
|
|
52
|
+
- `delete` removes a stored pathname.
|
|
53
|
+
- `response` answers one pending browser request.
|
|
54
|
+
- `close` immediately destroys the session.
|
|
55
|
+
|
|
56
|
+
All commands accept an optional string `id`. Successful `put`, `delete`, `response`, and `close` commands emit a correlated `ack`.
|
|
57
|
+
|
|
58
|
+
A resource supports `status`, `headers`, `content_type`, `encoding`, and `body`. `status` defaults to `200`, `encoding` to `utf8`, and `body` to an empty string. Header values may be strings or string arrays; arrays preserve repeated headers such as `Set-Cookie`. `content_type` overrides any `Content-Type` header. Binary bodies use base64:
|
|
59
|
+
|
|
60
|
+
```json
|
|
61
|
+
{"type":"put","path":"/logo.png","content_type":"image/png","encoding":"base64","body":"iVBORw0KGgo..."}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Delete a resource:
|
|
65
|
+
|
|
66
|
+
```json
|
|
67
|
+
{"type":"delete","id":"4","path":"/old.html"}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Destroy everything immediately:
|
|
71
|
+
|
|
72
|
+
```json
|
|
73
|
+
{"type":"close","id":"5"}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Events: LetMeKnow to stdout
|
|
77
|
+
|
|
78
|
+
- `session` contains the public URL and disconnect grace period.
|
|
79
|
+
- `request` describes a browser request that did not match a stored resource.
|
|
80
|
+
- `ack` confirms a command.
|
|
81
|
+
- `error` reports a command or protocol error.
|
|
82
|
+
- `closing` reports explicit destruction.
|
|
83
|
+
|
|
84
|
+
Unknown paths are sent to the producer:
|
|
85
|
+
|
|
86
|
+
```json
|
|
87
|
+
{"type":"request","id":"cf-request-id","method":"POST","path":"/answer","query":"step=2","headers":{"content-type":"application/x-www-form-urlencoded","hx-request":"true"},"encoding":"utf8","body":"answer=yes"}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Answer with the request event's `id` as `request_id`:
|
|
91
|
+
|
|
92
|
+
```json
|
|
93
|
+
{"type":"response","id":"6","request_id":"cf-request-id","status":200,"headers":{"content-type":"text/html; charset=utf-8","hx-trigger":"answered"},"body":"<strong>Accepted</strong>"}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Dynamic responses are not stored. Send a separate `put` to serve a path without involving the producer next time.
|
|
97
|
+
|
|
98
|
+
Production sessions use isolated `*.letmeknow.dev` origins, so root-relative links, forms, and asset URLs work normally. Local sessions use `/s/<code>/`; use relative URLs there. Stored paths ignore the URL query when matching. Dynamic events contain pathname and query separately. Methods, forms, cookies, HTMX headers, SPA API requests, status, response headers, and text or binary bodies pass through generically. Browser request bodies use UTF-8 only for recognized textual media types; absent, unrecognized, or invalid UTF-8 bodies use base64. Request, response, and stored-resource bodies are bounded to 1 MiB and are non-streaming; a stalled dynamic browser request body times out after 30 seconds, and a producer response may take up to 5 minutes. Stored and dynamic responses default to `Cache-Control: no-store`; an explicit producer header overrides that default. A session accepts at most 100 stored resources (10 MiB decoded total) and 32 simultaneous dynamic requests.
|
|
99
|
+
|
|
100
|
+
## Lifecycle
|
|
101
|
+
|
|
102
|
+
The active CLI connection owns the session. The CLI receives an unguessable private reconnect credential in a private WebSocket message, keeps it off protocol stdout, and sends it as the WebSocket subprotocol on reconnect. The reconnect URL contains only the public session code. If the connection drops unexpectedly, it reconnects with that credential during the ten-minute grace period without requiring another `open` command; each connection attempt has a ten-second deadline, and a failed initial attempt exits nonzero while reconnect attempts continue within the grace period. Stored resources remain available for ten minutes, while unknown paths return `503`. Browser traffic does not extend the grace period. After the alarm fires all resources are deleted. A producer connection that never sends `open` is cleaned up after a short deadline. `close` deletes everything immediately.
|
|
103
|
+
|
|
104
|
+
## Development
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
npm install
|
|
108
|
+
npm run dev
|
|
109
|
+
npm test
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
The Worker uses one Durable Object per session. The object owns the producer WebSocket, stored resources, pending browser requests, and disconnect alarm. No D1 or R2 binding is required.
|
|
113
|
+
|
|
114
|
+
Deploy with:
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
npm run deploy
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Production subdomain URLs require a proxied `*.letmeknow.dev` DNS record and a Worker route for `*.letmeknow.dev/*` in Cloudflare. The apex `letmeknow.dev` remains the control endpoint.
|
package/SKILL.md
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: letmeknow
|
|
3
|
+
description: Create a temporary browser surface for a human, serve static resources, and handle interactive HTTP requests through the LetMeKnow NDJSON CLI.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# LetMeKnow
|
|
7
|
+
|
|
8
|
+
Use LetMeKnow when a human needs a temporary web page or interactive UI from an agent. It is not a localhost proxy and does not read files or directories.
|
|
9
|
+
|
|
10
|
+
## Start the CLI
|
|
11
|
+
|
|
12
|
+
Node.js 22 or newer is required.
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npx letmeknow-cli
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
This connects to `https://letmeknow.dev` by default. To use another trusted deployment:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
LETMEKNOW_URL=http://localhost:8787 npx letmeknow-cli
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Run the CLI as a long-lived child process. Write one compact JSON object per line to stdin, keep stdin open while the session is active, and read one JSON event per line from stdout. Read diagnostics from stderr separately. Do not mix stderr into the NDJSON stream.
|
|
25
|
+
|
|
26
|
+
## Open and publish static content
|
|
27
|
+
|
|
28
|
+
`open` must be the first command. String `id` values are optional; use them to correlate results.
|
|
29
|
+
|
|
30
|
+
```json
|
|
31
|
+
{"type":"open","id":"open-1"}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Wait for the `session` event and retain its URL:
|
|
35
|
+
|
|
36
|
+
```json
|
|
37
|
+
{"type":"session","id":"open-1","url":"https://0123456789abcdef0123.letmeknow.dev/","expires_after_disconnect":600}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Store or replace exact pathnames with `put`. The following page submits to an unstored path so the agent can handle it dynamically:
|
|
41
|
+
|
|
42
|
+
```json
|
|
43
|
+
{"type":"put","id":"put-1","path":"/","content_type":"text/html; charset=utf-8","body":"<!doctype html><form method=\"post\" action=\"answer\"><label>Answer <input name=\"answer\"></label><button>Send</button></form>"}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Use relative links, form actions, and asset URLs so pages also work on local deployments, whose session URL includes a path prefix. Wait for `{"type":"ack","id":"put-1"}` before relying on the update. `status` defaults to `200`, `encoding` to `utf8`, and `body` to an empty string. `headers` accepts string values or string arrays; `content_type` overrides `Content-Type`. For binary content, set `encoding` to `base64`. Paths must start with `/` and must not contain a query. Query strings do not affect stored-path matching.
|
|
47
|
+
|
|
48
|
+
Remove static content with:
|
|
49
|
+
|
|
50
|
+
```json
|
|
51
|
+
{"type":"delete","id":"delete-1","path":"/old.html"}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Handle dynamic requests
|
|
55
|
+
|
|
56
|
+
A browser request whose path is not stored produces a `request` event:
|
|
57
|
+
|
|
58
|
+
```json
|
|
59
|
+
{"type":"request","id":"cf-request-id","method":"POST","path":"/answer","query":"step=2","headers":{"content-type":"application/x-www-form-urlencoded"},"encoding":"utf8","body":"answer=yes"}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Reply using the event's `id` as `request_id`:
|
|
63
|
+
|
|
64
|
+
```json
|
|
65
|
+
{"type":"response","id":"response-1","request_id":"cf-request-id","status":200,"content_type":"text/html; charset=utf-8","body":"<strong>Accepted</strong>"}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Wait for the correlated `ack`. Dynamic responses are not stored; use `put` if later requests should receive the same response without involving the agent. Treat request bodies and headers as untrusted input, and escape or validate values before placing them in HTML, headers, or commands.
|
|
69
|
+
|
|
70
|
+
The stdout event types are `session`, `request`, `ack`, `error`, and `closing`. Handle `error` rather than assuming a command succeeded.
|
|
71
|
+
|
|
72
|
+
## Close and lifecycle
|
|
73
|
+
|
|
74
|
+
Destroy the session and all content immediately when finished:
|
|
75
|
+
|
|
76
|
+
```json
|
|
77
|
+
{"type":"close","id":"close-1"}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Wait for `ack` and `closing`, then let the process exit. Closing stdin or terminating the CLI only disconnects the producer; it does not replace an explicit `close`.
|
|
81
|
+
|
|
82
|
+
The CLI owns the session and automatically reconnects after an unexpected disconnect. Each connection attempt has a 10-second deadline. Reconnection is possible only during the 10-minute disconnect grace period; stored resources remain available then, but unstored paths return `503`, and browser traffic does not extend the grace period. An initial producer connection must send `open` within 30 seconds. Dynamic request bodies have a 30-second read deadline, and a `response` may take at most 5 minutes.
|
|
83
|
+
|
|
84
|
+
Limits per session: 1 MiB per stored, request, or response body; 100 stored resources; 10 MiB decoded stored content in total; and 32 simultaneous dynamic requests. Bodies are non-streaming. Default responses include `Cache-Control: no-store` unless explicitly overridden.
|
|
85
|
+
|
|
86
|
+
## Security
|
|
87
|
+
|
|
88
|
+
Treat the session URL as a bearer secret: anyone who has it can access the surface and submit requests. Share it only with the intended human, and do not put it in source control, public logs, issue trackers, or unrelated output. The CLI also receives a separate private reconnect credential over the WebSocket, keeps it off protocol stdout, and uses it automatically. Never expose, persist, or ask the human for that credential. Use only a trusted `LETMEKNOW_URL`, because the deployment receives all page content and browser traffic.
|
|
89
|
+
|
|
90
|
+
## Recommended skill output
|
|
91
|
+
|
|
92
|
+
The minimal package interface should be:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
npx letmeknow-cli --skill
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
It should print this exact `SKILL.md` byte-for-byte to stdout and exit successfully without opening a network connection.
|
package/bin/letmeknow.js
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { readFileSync, writeSync } from "node:fs";
|
|
4
|
+
import readline from "node:readline";
|
|
5
|
+
|
|
6
|
+
if (process.argv[2] === "--skill") {
|
|
7
|
+
if (process.argv.length !== 3) {
|
|
8
|
+
process.stderr.write("Usage: npx letmeknow-cli --skill\n");
|
|
9
|
+
process.exit(1);
|
|
10
|
+
}
|
|
11
|
+
writeSync(1, readFileSync(new URL("../SKILL.md", import.meta.url)));
|
|
12
|
+
process.exit(0);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const control = new URL(process.env.LETMEKNOW_URL || "https://letmeknow.dev");
|
|
16
|
+
const graceSeconds = 10 * 60;
|
|
17
|
+
const connectionAttemptTimeout = 10_000;
|
|
18
|
+
const maxRetryDelay = 5_000;
|
|
19
|
+
const subprotocolTokenPattern = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
|
20
|
+
let socket;
|
|
21
|
+
let input;
|
|
22
|
+
let credential;
|
|
23
|
+
let sessionUrl;
|
|
24
|
+
let retryTimer;
|
|
25
|
+
let connectionTimer;
|
|
26
|
+
let retryDelay = 100;
|
|
27
|
+
let stdinClosed = false;
|
|
28
|
+
let signalRequested = false;
|
|
29
|
+
let explicitSessionClosed = false;
|
|
30
|
+
let closeCommandAccepted = false;
|
|
31
|
+
let closeCommandSent = false;
|
|
32
|
+
let retryUntil = 0;
|
|
33
|
+
let connected = false;
|
|
34
|
+
let finished = false;
|
|
35
|
+
const queued = [];
|
|
36
|
+
|
|
37
|
+
function endpoint() {
|
|
38
|
+
const url = new URL(control);
|
|
39
|
+
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
40
|
+
url.pathname = "/v1/connect";
|
|
41
|
+
url.search = "";
|
|
42
|
+
url.hash = "";
|
|
43
|
+
if (credential && sessionUrl) {
|
|
44
|
+
const publicUrl = new URL(sessionUrl);
|
|
45
|
+
const hostCode = publicUrl.hostname.match(/^([a-f0-9]{20})\.letmeknow\.dev$/);
|
|
46
|
+
const pathCode = publicUrl.pathname.match(/^\/s\/([a-f0-9]{20})(?:\/|$)/);
|
|
47
|
+
const code = hostCode?.[1] || pathCode?.[1];
|
|
48
|
+
if (code) {
|
|
49
|
+
url.searchParams.set("code", code);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return url;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function clearConnectionTimer() {
|
|
56
|
+
if (connectionTimer) clearTimeout(connectionTimer);
|
|
57
|
+
connectionTimer = undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function finish(code) {
|
|
61
|
+
if (finished) return;
|
|
62
|
+
finished = true;
|
|
63
|
+
if (retryTimer) clearTimeout(retryTimer);
|
|
64
|
+
clearConnectionTimer();
|
|
65
|
+
input?.close();
|
|
66
|
+
process.exitCode = code;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function validCredential(value) {
|
|
70
|
+
return typeof value === "string" && subprotocolTokenPattern.test(value);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function validSessionUrl(value) {
|
|
74
|
+
if (typeof value !== "string") return false;
|
|
75
|
+
let url;
|
|
76
|
+
try {
|
|
77
|
+
url = new URL(value);
|
|
78
|
+
} catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return false;
|
|
82
|
+
if (/^[a-f0-9]{20}\.letmeknow\.dev$/.test(url.hostname)) return true;
|
|
83
|
+
return /^\/s\/[a-f0-9]{20}(?:\/|$)/.test(url.pathname);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function isCloseCommand(line) {
|
|
87
|
+
try {
|
|
88
|
+
const packet = JSON.parse(line);
|
|
89
|
+
return packet !== null
|
|
90
|
+
&& typeof packet === "object"
|
|
91
|
+
&& !Array.isArray(packet)
|
|
92
|
+
&& packet.type === "close"
|
|
93
|
+
&& (packet.id === undefined || typeof packet.id === "string");
|
|
94
|
+
} catch {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function send(line) {
|
|
100
|
+
if (!socket || !connected || socket.readyState !== WebSocket.OPEN) return false;
|
|
101
|
+
try {
|
|
102
|
+
socket.send(line);
|
|
103
|
+
if (isCloseCommand(line)) closeCommandSent = true;
|
|
104
|
+
return true;
|
|
105
|
+
} catch {
|
|
106
|
+
try {
|
|
107
|
+
socket.close();
|
|
108
|
+
} catch {
|
|
109
|
+
// The close event still determines whether reconnect is needed.
|
|
110
|
+
}
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function sendOrQueue(line) {
|
|
116
|
+
if (finished || closeCommandAccepted) return;
|
|
117
|
+
if (isCloseCommand(line)) closeCommandAccepted = true;
|
|
118
|
+
if (!send(line)) queued.push(line);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function flush() {
|
|
122
|
+
if (finished) return;
|
|
123
|
+
while (queued.length && !closeCommandSent) {
|
|
124
|
+
if (!send(queued[0])) break;
|
|
125
|
+
queued.shift();
|
|
126
|
+
}
|
|
127
|
+
if (closeCommandSent) queued.length = 0;
|
|
128
|
+
if (!queued.length && stdinClosed && !closeCommandSent && socket && connected) {
|
|
129
|
+
socket.close(1000, "stdin closed");
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function protocolFailure(message) {
|
|
134
|
+
if (finished) return;
|
|
135
|
+
process.stderr.write(`letmeknow: server protocol error: ${message}\n`);
|
|
136
|
+
const current = socket;
|
|
137
|
+
if (current) {
|
|
138
|
+
try {
|
|
139
|
+
current.close(1000, "protocol error");
|
|
140
|
+
} catch {
|
|
141
|
+
// The process still exits below.
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
finish(1);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function handleMessage(event) {
|
|
148
|
+
if (finished) return;
|
|
149
|
+
if (typeof event.data !== "string") {
|
|
150
|
+
protocolFailure("binary WebSocket frame");
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const text = event.data;
|
|
154
|
+
let packet;
|
|
155
|
+
try {
|
|
156
|
+
packet = JSON.parse(text);
|
|
157
|
+
} catch {
|
|
158
|
+
protocolFailure("invalid JSON");
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (!packet || typeof packet !== "object" || Array.isArray(packet)) {
|
|
162
|
+
protocolFailure("packet must be a JSON object");
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (typeof packet.type !== "string") {
|
|
166
|
+
protocolFailure("packet type is required");
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (packet.type === "credential") {
|
|
170
|
+
if (!validCredential(packet.credential)) {
|
|
171
|
+
protocolFailure("invalid credential");
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
credential = packet.credential;
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
if (packet.type === "session") {
|
|
178
|
+
if (!validSessionUrl(packet.url)) {
|
|
179
|
+
protocolFailure("invalid session URL");
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (typeof packet.expires_after_disconnect !== "number"
|
|
183
|
+
|| !Number.isFinite(packet.expires_after_disconnect)
|
|
184
|
+
|| packet.expires_after_disconnect <= 0) {
|
|
185
|
+
protocolFailure("invalid session expiration");
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
sessionUrl = packet.url;
|
|
189
|
+
retryDelay = 100;
|
|
190
|
+
}
|
|
191
|
+
if (packet.type === "closing") explicitSessionClosed = true;
|
|
192
|
+
process.stdout.write(`${text}\n`);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function retry() {
|
|
196
|
+
if (finished || signalRequested || explicitSessionClosed || closeCommandSent || (stdinClosed && !queued.length) || Date.now() >= retryUntil) {
|
|
197
|
+
finish(explicitSessionClosed || signalRequested || closeCommandSent || (stdinClosed && !queued.length) ? 0 : 1);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
retryTimer = setTimeout(() => {
|
|
201
|
+
retryTimer = undefined;
|
|
202
|
+
start();
|
|
203
|
+
}, retryDelay);
|
|
204
|
+
retryDelay = Math.min(retryDelay * 2, maxRetryDelay);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function start() {
|
|
208
|
+
if (finished || signalRequested || explicitSessionClosed || closeCommandSent) return;
|
|
209
|
+
const reconnecting = Boolean(credential && sessionUrl);
|
|
210
|
+
const current = socket = reconnecting
|
|
211
|
+
? new WebSocket(endpoint(), credential)
|
|
212
|
+
: new WebSocket(endpoint());
|
|
213
|
+
connectionTimer = setTimeout(() => {
|
|
214
|
+
if (socket !== current || connected || finished) return;
|
|
215
|
+
clearConnectionTimer();
|
|
216
|
+
process.stderr.write("letmeknow: WebSocket connection attempt timed out\n");
|
|
217
|
+
try {
|
|
218
|
+
current.close();
|
|
219
|
+
} catch {
|
|
220
|
+
// The close event is not available when construction failed.
|
|
221
|
+
}
|
|
222
|
+
socket = undefined;
|
|
223
|
+
connected = false;
|
|
224
|
+
if (reconnecting) retry();
|
|
225
|
+
else finish(1);
|
|
226
|
+
}, connectionAttemptTimeout);
|
|
227
|
+
current.addEventListener("message", handleMessage);
|
|
228
|
+
current.addEventListener("error", () => {
|
|
229
|
+
if (!finished) process.stderr.write("letmeknow: WebSocket connection failed; retrying\n");
|
|
230
|
+
});
|
|
231
|
+
current.addEventListener("open", () => {
|
|
232
|
+
if (socket !== current || finished) return;
|
|
233
|
+
clearConnectionTimer();
|
|
234
|
+
connected = true;
|
|
235
|
+
retryDelay = 100;
|
|
236
|
+
if (reconnecting) retryUntil = 0;
|
|
237
|
+
flush();
|
|
238
|
+
});
|
|
239
|
+
current.addEventListener("close", (event) => {
|
|
240
|
+
if (socket !== current) return;
|
|
241
|
+
clearConnectionTimer();
|
|
242
|
+
connected = false;
|
|
243
|
+
if (finished) return;
|
|
244
|
+
socket = undefined;
|
|
245
|
+
if (closeCommandSent || signalRequested || explicitSessionClosed || (stdinClosed && !queued.length)) {
|
|
246
|
+
finish(0);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (!credential || !sessionUrl) {
|
|
250
|
+
process.stderr.write(`letmeknow: connection closed (${event.code}${event.reason ? `: ${event.reason}` : ""})\n`);
|
|
251
|
+
finish(1);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
if (!retryUntil) retryUntil = Date.now() + graceSeconds * 1_000;
|
|
255
|
+
retry();
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
input = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
|
|
260
|
+
input.on("line", (line) => {
|
|
261
|
+
if (!finished && line.trim() && !closeCommandAccepted) sendOrQueue(line);
|
|
262
|
+
});
|
|
263
|
+
input.on("close", () => {
|
|
264
|
+
if (finished) return;
|
|
265
|
+
stdinClosed = true;
|
|
266
|
+
if (!socket) {
|
|
267
|
+
if (!queued.length) finish(0);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if (connected) flush();
|
|
271
|
+
else if (!queued.length) {
|
|
272
|
+
const current = socket;
|
|
273
|
+
clearConnectionTimer();
|
|
274
|
+
try {
|
|
275
|
+
current.close();
|
|
276
|
+
} catch {
|
|
277
|
+
// The process still exits below.
|
|
278
|
+
}
|
|
279
|
+
socket = undefined;
|
|
280
|
+
finish(0);
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
285
|
+
process.on(signal, () => {
|
|
286
|
+
signalRequested = true;
|
|
287
|
+
clearConnectionTimer();
|
|
288
|
+
if (socket) {
|
|
289
|
+
try {
|
|
290
|
+
socket.close(1000, signal);
|
|
291
|
+
} catch {
|
|
292
|
+
// The process still exits below.
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
finish(0);
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
start();
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "letmeknow-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A temporary interactive web surface for agents.",
|
|
5
|
+
"files": ["bin", "SKILL.md"],
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"letmeknow": "bin/letmeknow.js"
|
|
9
|
+
},
|
|
10
|
+
"engines": {
|
|
11
|
+
"node": ">=22"
|
|
12
|
+
},
|
|
13
|
+
"scripts": {
|
|
14
|
+
"dev": "wrangler dev",
|
|
15
|
+
"deploy": "wrangler deploy",
|
|
16
|
+
"check": "tsc --noEmit && tsc -p tsconfig.test.json --noEmit",
|
|
17
|
+
"test": "npm run check && vitest run && node --test tests/cli.test.js"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@cloudflare/vitest-plugin": "^1.1.0",
|
|
21
|
+
"@cloudflare/workers-types": "^5.20260825.1",
|
|
22
|
+
"@types/node": "^22.15.17",
|
|
23
|
+
"typescript": "^5.9.2",
|
|
24
|
+
"vitest": "^4.1.11",
|
|
25
|
+
"wrangler": "^4.126.0",
|
|
26
|
+
"ws": "^8.21.3"
|
|
27
|
+
}
|
|
28
|
+
}
|