@rubytech/create-maxy-lite 0.1.5 → 0.1.6

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 (32) hide show
  1. package/index.mjs +15 -14
  2. package/lib/orchestrate.mjs +2 -2
  3. package/lib/paths.mjs +35 -0
  4. package/package.json +1 -1
  5. package/payload/package.json +2 -1
  6. package/payload/skills/README.md +26 -0
  7. package/payload/skills/admin/datetime/SKILL.md +147 -0
  8. package/payload/skills/admin/session-management/SKILL.md +39 -0
  9. package/payload/skills/admin/upgrade/SKILL.md +32 -0
  10. package/payload/skills/browser/SKILL.md +60 -0
  11. package/payload/skills/browser/scripts/cdp.mjs +134 -0
  12. package/payload/skills/browser/scripts/pdf.mjs +38 -0
  13. package/payload/skills/browser/scripts/render.mjs +43 -0
  14. package/payload/skills/browser/scripts/screenshot.mjs +52 -0
  15. package/payload/skills/business-assistant/SKILL.md +110 -0
  16. package/payload/skills/deep-research/SKILL.md +70 -0
  17. package/payload/skills/deep-research/references/citation-styles.md +52 -0
  18. package/payload/skills/deep-research/references/research-modes.md +22 -0
  19. package/payload/skills/deep-research/references/search-strategy.md +24 -0
  20. package/payload/skills/docs/SKILL.md +23 -0
  21. package/payload/skills/docs/references/capability-map.md +25 -0
  22. package/payload/skills/docs/references/getting-started.md +29 -0
  23. package/payload/skills/docs/references/vault-model.md +40 -0
  24. package/payload/skills/email-composition/SKILL.md +107 -0
  25. package/payload/skills/replicate/SKILL.md +63 -0
  26. package/payload/skills/replicate/scripts/replicate-image.mjs +131 -0
  27. package/payload/skills/url-get/SKILL.md +48 -0
  28. package/payload/skills/url-get/scripts/url-get.mjs +93 -0
  29. package/payload/webchat/inject-line.mjs +11 -0
  30. package/payload/webchat/package.json +2 -1
  31. package/payload/webchat/request-handler.mjs +62 -0
  32. package/payload/webchat/server.mjs +31 -31
@@ -0,0 +1,63 @@
1
+ ---
2
+ name: replicate
3
+ description: >
4
+ Generate an image from a text prompt. Use when the owner wants a picture,
5
+ logo, illustration, diagram, icon, or any visual created from a description.
6
+ Trigger phrases: "generate an image", "make a picture of", "create a logo",
7
+ "draw me", "design an illustration", "I need a graphic of". Produces a real
8
+ image file in the vault.
9
+ ---
10
+
11
+ # replicate
12
+
13
+ Turn a text prompt into an image using the Replicate API. The script picks the model, calls the API, downloads the result into the vault, and prints the file path.
14
+
15
+ ## When to use
16
+
17
+ The owner wants an image made from a description: a logo, an illustration, a concept sketch, a photoreal scene, a diagram, an icon. This produces an actual file, not a description of one.
18
+
19
+ ## Models
20
+
21
+ Pick the model to match the job:
22
+
23
+ - `nano-banana-pro` (default): photorealistic, data-driven visuals.
24
+ - `recraft-v4`: design compositions and branded assets. The only model that can return `svg`.
25
+ - `flux-schnell`: fast drafts and concept sketches.
26
+
27
+ ## How to run
28
+
29
+ ```bash
30
+ node ~/.maxy-lite/skills/replicate/scripts/replicate-image.mjs \
31
+ --prompt="a calm mountain lake at dawn, soft light" \
32
+ --model=nano-banana-pro \
33
+ --aspect=16:9 \
34
+ --format=png
35
+ ```
36
+
37
+ - `--prompt` (required): what to draw.
38
+ - `--model` (optional, default `nano-banana-pro`): one of the three above.
39
+ - `--aspect` (optional, default `1:1`): one of `1:1`, `4:3`, `3:2`, `16:9`, `21:9`, `9:16`.
40
+ - `--format` (optional, default `png`): `png`, `jpg`, `webp`, or `svg` (svg only with `recraft-v4`).
41
+
42
+ On success the file path prints to stdout and the image is saved under `$LITE_VAULT/Assets/generated/`. Show the owner the path.
43
+
44
+ ## Token
45
+
46
+ The script reads `REPLICATE_API_TOKEN` from the environment, or falls back to `~/.replicate/api-token`. The lite runtime supplies the token from its secrets. If neither is set the script exits with `error=no-token`. Tell the owner a Replicate token is needed and stop.
47
+
48
+ ## Reading the result
49
+
50
+ - **A file path on stdout, exit 0**: the image was generated and saved.
51
+ - **Non-zero exit**: an error on stderr as `[replicate] error=<code>`:
52
+ - `no-token`: no token configured.
53
+ - `auth`: the token was rejected.
54
+ - `network`: the Replicate API was unreachable.
55
+ - `model`: an unknown model, a bad model option, or the generation failed.
56
+ - `download`: the image was generated but could not be fetched.
57
+ - `disk`: the image could not be written to the vault.
58
+
59
+ Surface the error plainly. Never claim an image was made when the script failed.
60
+
61
+ ## Boundaries
62
+
63
+ Generates one image per call. Does not edit existing images, run image-to-image, or upscale. Those need a different model and are not wired here.
@@ -0,0 +1,131 @@
1
+ #!/usr/bin/env node
2
+ // replicate-image — image generation via the Replicate HTTP API. Zero deps.
3
+ //
4
+ // Ported from the maxy-code replicate MCP tool to a single lite CLI script.
5
+ // Uses native fetch against the Replicate predictions API (no replicate SDK, no
6
+ // nanoid). Same three-model registry and aspect-ratio table. The generated image
7
+ // is downloaded into the vault assets directory and its path printed to stdout.
8
+ //
9
+ // Token resolution (parity with maxy-code): REPLICATE_API_TOKEN env first, then
10
+ // ~/.replicate/api-token. Errors go to stderr as [replicate] error=<code> and
11
+ // exit non-zero. Codes: usage, no-token, model, auth, network, download, disk.
12
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
13
+ import { homedir } from "node:os";
14
+ import { join, resolve } from "node:path";
15
+ import { randomUUID } from "node:crypto";
16
+
17
+ const MODELS = {
18
+ "nano-banana-pro": { owner: "google", name: "nano-banana-pro" },
19
+ "recraft-v4": { owner: "recraft-ai", name: "recraft-v4", svg: "recraft-v4-svg" },
20
+ "flux-schnell": { owner: "black-forest-labs", name: "flux-schnell" },
21
+ };
22
+
23
+ const ASPECT = {
24
+ "1:1": { width: 1024, height: 1024 },
25
+ "4:3": { width: 1024, height: 768 },
26
+ "3:2": { width: 1024, height: 683 },
27
+ "16:9": { width: 1024, height: 576 },
28
+ "21:9": { width: 1344, height: 576 },
29
+ "9:16": { width: 576, height: 1024 },
30
+ };
31
+
32
+ const fail = (code, msg) => {
33
+ console.error(`[replicate] error=${code} detail=${JSON.stringify(msg)}`);
34
+ process.exit(1);
35
+ };
36
+
37
+ const args = Object.fromEntries(
38
+ process.argv.slice(2).map((a) => {
39
+ const m = a.match(/^--([^=]+)=(.*)$/);
40
+ return m ? [m[1], m[2]] : [a, true];
41
+ }),
42
+ );
43
+ const prompt = args.prompt;
44
+ const model = args.model || "nano-banana-pro";
45
+ const aspectRatio = args.aspect || "1:1";
46
+ const outputFormat = args.format || "png";
47
+ if (!prompt || prompt === true) {
48
+ fail("usage", "usage: replicate-image.mjs --prompt=<text> [--model=] [--aspect=] [--format=]");
49
+ }
50
+
51
+ const token =
52
+ process.env.REPLICATE_API_TOKEN ||
53
+ (existsSync(join(homedir(), ".replicate", "api-token"))
54
+ ? readFileSync(join(homedir(), ".replicate", "api-token"), "utf-8").trim()
55
+ : "");
56
+ if (!token) fail("no-token", "no Replicate token (set REPLICATE_API_TOKEN or ~/.replicate/api-token)");
57
+
58
+ const entry = MODELS[model];
59
+ if (!entry) fail("model", `unknown model: ${model} (use nano-banana-pro, recraft-v4, or flux-schnell)`);
60
+ if (outputFormat === "svg" && model !== "recraft-v4") fail("model", "svg output is only available with recraft-v4");
61
+ const name = outputFormat === "svg" && entry.svg ? entry.svg : entry.name;
62
+
63
+ const dims = ASPECT[aspectRatio] ?? ASPECT["1:1"];
64
+ const input =
65
+ model === "flux-schnell"
66
+ ? { prompt, aspect_ratio: aspectRatio, ...(outputFormat !== "svg" ? { output_format: outputFormat } : {}) }
67
+ : { prompt, width: dims.width, height: dims.height, ...(outputFormat !== "svg" ? { output_format: outputFormat } : {}) };
68
+
69
+ // Create a prediction against the model. Prefer:wait blocks until the prediction
70
+ // is terminal where the model supports it; otherwise we poll the get URL.
71
+ const headers = { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Prefer: "wait" };
72
+ let pred;
73
+ try {
74
+ const r = await fetch(`https://api.replicate.com/v1/models/${entry.owner}/${name}/predictions`, {
75
+ method: "POST",
76
+ headers,
77
+ body: JSON.stringify({ input }),
78
+ });
79
+ if (r.status === 401 || r.status === 403) fail("auth", "Replicate token rejected (invalid or revoked)");
80
+ if (!r.ok) fail("model", `Replicate API HTTP ${r.status}: ${(await r.text()).slice(0, 200)}`);
81
+ pred = await r.json();
82
+ } catch (err) {
83
+ fail("network", err instanceof Error ? err.message : String(err));
84
+ }
85
+
86
+ while (pred.status && pred.status !== "succeeded" && pred.status !== "failed" && pred.status !== "canceled") {
87
+ if (!pred.urls?.get) fail("model", "prediction is not terminal but carries no poll URL");
88
+ await new Promise((r) => setTimeout(r, 1500));
89
+ try {
90
+ const r = await fetch(pred.urls.get, { headers: { Authorization: `Bearer ${token}` } });
91
+ pred = await r.json();
92
+ } catch (err) {
93
+ fail("network", err instanceof Error ? err.message : String(err));
94
+ }
95
+ }
96
+ if (pred.status !== "succeeded") {
97
+ fail("model", `generation ${pred.status}: ${JSON.stringify(pred.error ?? "").slice(0, 200)}`);
98
+ }
99
+
100
+ // Replicate output is a URL string or an array of URL strings, depending on model.
101
+ const out = pred.output;
102
+ const imageUrl = Array.isArray(out)
103
+ ? out.find((u) => typeof u === "string" && u.startsWith("http"))
104
+ : typeof out === "string" && out.startsWith("http")
105
+ ? out
106
+ : null;
107
+ if (!imageUrl) fail("model", "generation returned unexpected output format");
108
+
109
+ let buf;
110
+ try {
111
+ const r = await fetch(imageUrl);
112
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
113
+ buf = Buffer.from(await r.arrayBuffer());
114
+ } catch (err) {
115
+ fail("download", err instanceof Error ? err.message : String(err));
116
+ }
117
+
118
+ const ext = outputFormat === "svg" ? "svg" : outputFormat;
119
+ const date = new Date().toISOString().slice(0, 10);
120
+ const vault = process.env.LITE_VAULT || join(homedir(), ".maxy-lite", "vault");
121
+ const genDir = resolve(vault, "Assets", "generated");
122
+ const file = resolve(genDir, `${date}-${randomUUID().slice(0, 6)}.${ext}`);
123
+ try {
124
+ mkdirSync(genDir, { recursive: true });
125
+ writeFileSync(file, buf);
126
+ } catch (err) {
127
+ fail("disk", err instanceof Error ? err.message : String(err));
128
+ }
129
+
130
+ console.error(`[replicate] complete model=${model} format=${outputFormat} aspect=${aspectRatio} file=${file}`);
131
+ process.stdout.write(file + "\n");
@@ -0,0 +1,48 @@
1
+ ---
2
+ name: url-get
3
+ description: >
4
+ Fetch a web page faithfully and read its content as markdown. Use when the
5
+ owner gives a URL and wants the page read, quoted, or summarised, or when you
6
+ need the verbatim text of an article, doc, or listing. Trigger phrases:
7
+ "fetch this page", "get the content of", "read this article", "what does this
8
+ link say", "pull the text from", "open this URL and tell me". For
9
+ JavaScript-heavy pages that come back empty, fall to the browser skill.
10
+ ---
11
+
12
+ # url-get
13
+
14
+ Retrieve a web page exactly as the server sent it and read it as markdown. There is no model in the fetch path, so nothing is summarised or inferred on the way in. The script prints the cleaned markdown and writes the full verbatim copy to a scratch file.
15
+
16
+ ## When to use
17
+
18
+ - The owner gives a URL and wants it read, quoted, fact-checked, or summarised.
19
+ - You need the real text of a page rather than a recollection of it.
20
+ - A research step (see `[[deep-research]]`) needs a source fetched.
21
+
22
+ Use this for server-rendered HTML, which is most of the web. For a page that runs its content in the browser (a client-rendered app), this returns little or nothing; switch to the `[[browser]]` render script.
23
+
24
+ ## How to run
25
+
26
+ ```bash
27
+ node ~/.maxy-lite/skills/url-get/scripts/url-get.mjs "https://example.com/article"
28
+ ```
29
+
30
+ The cleaned markdown prints to stdout. The full copy is written to `$LITE_SCRATCH/url-get/<hash>.md` (default `~/.maxy-lite/scratch/url-get/`) so a long page does not have to be re-fetched.
31
+
32
+ ## Reading the result
33
+
34
+ - **Markdown on stdout, exit 0**: the page text. Read it, then answer.
35
+ - **Empty stdout, exit 0**: the page is HTTP 200 but has no extractable text. This is the client-rendered-shell signal. Re-fetch with the `[[browser]]` render script, which runs the page's JavaScript first.
36
+ - **Non-zero exit**: a fetch error printed to stderr as `[url-get] error=<code>`:
37
+ - `fetch`: network failure or timeout.
38
+ - `http`: the server returned a non-2xx status.
39
+ - `non-html`: the URL is a PDF, image, or other non-HTML type this script does not handle.
40
+ - `challenge`: the response was a bot-challenge interstitial, not content. Try the `[[browser]]` render script, which presents a real browser.
41
+
42
+ Surface the error to the owner plainly. Do not invent the page contents.
43
+
44
+ ## Boundaries
45
+
46
+ - Reads one URL per call. For several pages, call it once per URL.
47
+ - Does not run page JavaScript, submit forms, or follow in-page actions. Those are the `[[browser]]` skill.
48
+ - Does not fetch PDFs or images. A PDF link needs a different tool.
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env node
2
+ // url-get — faithful HTTP page fetch into markdown. No model in the path.
3
+ //
4
+ // Ported from the maxy-code url-get MCP tool to a single lite CLI script.
5
+ // One mechanism: fetch the URL, refuse non-HTML and bot-challenge pages, convert
6
+ // the HTML to markdown verbatim, write the full copy to a scratch reference file,
7
+ // and print the same (capped) markdown to stdout. Nothing is summarised — a
8
+ // caller that needs a summary reads the text and summarises in its own context.
9
+ //
10
+ // FETCH -> HTTP STATUS -> CONTENT-TYPE -> CHALLENGE -> HTML->MD -> WRITE REF -> PRINT
11
+ //
12
+ // Errors go to stderr as [url-get] error=<code> and exit non-zero. Codes:
13
+ // fetch network failure / timeout
14
+ // http non-2xx response
15
+ // non-html content-type is not HTML or text/plain
16
+ // challenge response is a bot-challenge interstitial, not content
17
+ import { createHash } from "node:crypto";
18
+ import { mkdir, writeFile } from "node:fs/promises";
19
+ import { homedir } from "node:os";
20
+ import { join } from "node:path";
21
+ import { NodeHtmlMarkdown } from "node-html-markdown";
22
+
23
+ const FETCH_TIMEOUT_MS = 30_000;
24
+ const TEXT_CAP = 100_000;
25
+ const UA =
26
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " +
27
+ "(KHTML, like Gecko) Chrome/124.0 Safari/537.36";
28
+
29
+ // Strong, low-false-positive markers of a bot-challenge interstitial. These do
30
+ // not occur in legitimate article bodies; a match means the response is a
31
+ // challenge page, not the requested content.
32
+ const CHALLENGE = [
33
+ "/cdn-cgi/challenge-platform/",
34
+ "cf-browser-verification",
35
+ "enable javascript and cookies to continue",
36
+ "checking your browser before accessing",
37
+ "attention required! | cloudflare",
38
+ "<title>just a moment",
39
+ ];
40
+
41
+ const fail = (code, msg) => {
42
+ console.error(`[url-get] error=${code} detail=${JSON.stringify(msg)}`);
43
+ process.exit(1);
44
+ };
45
+
46
+ const url = process.argv[2];
47
+ if (!url) fail("usage", "usage: url-get.mjs <url>");
48
+
49
+ let res;
50
+ try {
51
+ res = await fetch(url, {
52
+ redirect: "follow",
53
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
54
+ headers: { "User-Agent": UA, Accept: "text/html,application/xhtml+xml" },
55
+ });
56
+ } catch (err) {
57
+ fail("fetch", err instanceof Error ? err.message : String(err));
58
+ }
59
+
60
+ if (!res.ok) fail("http", `HTTP ${res.status}`);
61
+
62
+ const ct = (res.headers.get("content-type") ?? "").toLowerCase();
63
+ if (ct && !ct.includes("html") && !ct.includes("text/plain")) {
64
+ fail("non-html", `unsupported content-type: ${ct} (url-get handles server-rendered HTML only)`);
65
+ }
66
+
67
+ const html = await res.text();
68
+ if (CHALLENGE.some((sig) => html.slice(0, 8000).toLowerCase().includes(sig))) {
69
+ fail("challenge", "page returned a bot-challenge interstitial, not content");
70
+ }
71
+
72
+ const markdown = NodeHtmlMarkdown.translate(html).trim();
73
+
74
+ // The full verbatim markdown is the ground truth on disk; stdout is the same
75
+ // content capped so a large page does not flood the caller's context. An empty
76
+ // markdown body (textLength=0) on an HTTP 200 is the JavaScript-rendered-shell
77
+ // signal: retry with the browser render script.
78
+ const scratch = process.env.LITE_SCRATCH || join(homedir(), ".maxy-lite", "scratch");
79
+ const dir = join(scratch, "url-get");
80
+ const hash = createHash("sha256").update(url).digest("hex").slice(0, 32);
81
+ const refPath = join(dir, `${hash}.md`);
82
+ const fetchedAt = new Date().toISOString();
83
+ await mkdir(dir, { recursive: true });
84
+ await writeFile(
85
+ refPath,
86
+ `<!-- url-get source=${url} fetchedAt=${fetchedAt} httpStatus=${res.status} -->\n\n${markdown}\n`,
87
+ "utf-8",
88
+ );
89
+
90
+ console.error(
91
+ `[url-get] url=${url} httpStatus=${res.status} textLength=${markdown.length} refPath=${refPath}`,
92
+ );
93
+ process.stdout.write(markdown.slice(0, TEXT_CAP));
@@ -0,0 +1,11 @@
1
+ // Turn a message into the byte sequence written into the claude pty.
2
+ //
3
+ // The TUI submits on a carriage return, so an embedded newline would submit
4
+ // early and truncate the message. Collapse every newline run to a single space,
5
+ // then append the one trailing CR that submits the whole line. Shared by the
6
+ // browser send path and the channel `/inject` path so both are protected.
7
+
8
+ /** @param {string} text @returns {string} */
9
+ export function ptyLine(text) {
10
+ return text.replace(/[\r\n]+/g, ' ') + '\r'
11
+ }
@@ -8,7 +8,8 @@
8
8
  "maxy-lite-webchat": "server.mjs"
9
9
  },
10
10
  "scripts": {
11
- "start": "node server.mjs"
11
+ "start": "node server.mjs",
12
+ "test": "node --test"
12
13
  },
13
14
  "dependencies": {
14
15
  "node-pty": "^1.0.0",
@@ -0,0 +1,62 @@
1
+ // HTTP request router for the webchat relay.
2
+ //
3
+ // Two surfaces:
4
+ // - GET /, /app.js, /style.css → static files from the public dir.
5
+ // - POST /inject {text} → deliver an inbound message to the agent.
6
+ //
7
+ // The `/inject` route is how the on-device channel servers (Telegram, WhatsApp)
8
+ // reach the one shared agent: they POST a context-tagged line here and it is
9
+ // written into the same pty a browser send uses. `onInject` is injected so the
10
+ // router stays testable without a live pty.
11
+
12
+ import fs from 'node:fs'
13
+ import path from 'node:path'
14
+
15
+ const CONTENT_TYPES = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css' }
16
+ const STATIC = { '/': 'index.html', '/app.js': 'app.js', '/style.css': 'style.css' }
17
+
18
+ /**
19
+ * @param {{ publicDir: string, onInject: (text: string) => void }} deps
20
+ * @returns {(req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse) => void}
21
+ */
22
+ export function makeRequestHandler({ publicDir, onInject }) {
23
+ return (req, res) => {
24
+ if (req.method === 'POST' && req.url === '/inject') {
25
+ let body = ''
26
+ req.on('data', (c) => (body += c))
27
+ req.on('end', () => {
28
+ let text
29
+ try {
30
+ text = JSON.parse(body).text
31
+ } catch {
32
+ text = undefined
33
+ }
34
+ if (typeof text !== 'string' || text === '') {
35
+ res.writeHead(400, { 'content-type': 'application/json' })
36
+ res.end('{"ok":false,"error":"text required"}')
37
+ return
38
+ }
39
+ onInject(text)
40
+ res.writeHead(200, { 'content-type': 'application/json' })
41
+ res.end('{"ok":true}')
42
+ })
43
+ return
44
+ }
45
+
46
+ const file = STATIC[req.url]
47
+ if (!file) {
48
+ res.writeHead(404)
49
+ res.end('not found')
50
+ return
51
+ }
52
+ fs.readFile(path.join(publicDir, file), (err, data) => {
53
+ if (err) {
54
+ res.writeHead(404)
55
+ res.end('not found')
56
+ return
57
+ }
58
+ res.writeHead(200, { 'content-type': CONTENT_TYPES[path.extname(file)] || 'text/plain' })
59
+ res.end(data)
60
+ })
61
+ }
62
+ }
@@ -22,6 +22,8 @@ import { fileURLToPath } from 'node:url'
22
22
  import pty from 'node-pty'
23
23
  import { WebSocketServer } from 'ws'
24
24
  import { parseTranscript } from './parse-transcript.mjs'
25
+ import { ptyLine } from './inject-line.mjs'
26
+ import { makeRequestHandler } from './request-handler.mjs'
25
27
 
26
28
  const HERE = path.dirname(fileURLToPath(import.meta.url))
27
29
  const PUBLIC_DIR = path.join(HERE, 'public')
@@ -166,28 +168,34 @@ setInterval(() => {
166
168
  }
167
169
  }, 500)
168
170
 
169
- // ---- transport: HTTP static + WebSocket -------------------------------------
171
+ // ---- deliver a message to the agent -----------------------------------------
172
+
173
+ // Start a turn and write the message into the pty. The single source of truth
174
+ // for both the browser send path and the channel `/inject` path, so a channel
175
+ // inbound is delivered byte-for-byte the same way a browser message is. An
176
+ // embedded newline is collapsed by ptyLine so the TUI is not submitted early.
177
+ function submitToAgent(text) {
178
+ turn += 1
179
+ turnActive = true
180
+ turnSawGrowth = false
181
+ turnStart = Date.now()
182
+ lastActivity = Date.now()
183
+ log('inbound', { turn, chars: text.length })
184
+ child.write(ptyLine(text))
185
+ log('spawn', { turn, pty: true, pid: child.pid })
186
+ }
170
187
 
171
- const CONTENT_TYPES = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css' }
172
- const STATIC = { '/': 'index.html', '/app.js': 'app.js', '/style.css': 'style.css' }
188
+ // ---- transport: HTTP static + /inject + WebSocket ---------------------------
173
189
 
174
- const server = http.createServer((req, res) => {
175
- const file = STATIC[req.url]
176
- if (!file) {
177
- res.writeHead(404)
178
- res.end('not found')
179
- return
180
- }
181
- fs.readFile(path.join(PUBLIC_DIR, file), (err, data) => {
182
- if (err) {
183
- res.writeHead(404)
184
- res.end('not found')
185
- return
186
- }
187
- res.writeHead(200, { 'content-type': CONTENT_TYPES[path.extname(file)] || 'text/plain' })
188
- res.end(data)
189
- })
190
- })
190
+ const server = http.createServer(
191
+ makeRequestHandler({
192
+ publicDir: PUBLIC_DIR,
193
+ onInject: (text) => {
194
+ log('inject', { chars: text.length })
195
+ submitToAgent(text)
196
+ },
197
+ }),
198
+ )
191
199
 
192
200
  const wss = new WebSocketServer({ server, path: '/ws' })
193
201
 
@@ -209,18 +217,10 @@ wss.on('connection', (ws) => {
209
217
  return
210
218
  }
211
219
  if (m.type !== 'send' || typeof m.text !== 'string' || m.text === '') return
212
- turn += 1
213
- turnActive = true
214
- turnSawGrowth = false
215
- turnStart = Date.now()
216
- lastActivity = Date.now()
217
- log('inbound', { turn, chars: m.text.length })
218
220
  // Deliver to claude by writing into the pty, then submit with a carriage
219
- // return. This is the TUI typing model — the same input path ttyd uses. An
220
- // embedded newline would submit the TUI early and truncate the message, so
221
- // collapse any newline to a space: v0 chat is single-line per send.
222
- child.write(m.text.replace(/[\r\n]+/g, ' ') + '\r')
223
- log('spawn', { turn, pty: true, pid: child.pid })
221
+ // return. This is the TUI typing model — the same input path ttyd uses, and
222
+ // the same path the channel `/inject` route uses via submitToAgent.
223
+ submitToAgent(m.text)
224
224
  })
225
225
  })
226
226