@rubytech/create-maxy-lite 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 +61 -0
- package/index.mjs +150 -0
- package/lib/healthcheck.mjs +41 -0
- package/lib/log.mjs +16 -0
- package/lib/orchestrate.mjs +148 -0
- package/lib/paths.mjs +50 -0
- package/lib/pins.mjs +22 -0
- package/package.json +33 -0
- package/payload/package.json +8 -0
- package/payload/schema/SCHEMA.md +404 -0
- package/payload/validator/cli.mjs +56 -0
- package/payload/validator/frontmatter.mjs +37 -0
- package/payload/validator/schema.mjs +147 -0
- package/payload/validator/validate.mjs +166 -0
- package/payload/webchat/README.md +55 -0
- package/payload/webchat/package-lock.json +56 -0
- package/payload/webchat/package.json +17 -0
- package/payload/webchat/parse-transcript.mjs +128 -0
- package/payload/webchat/public/app.js +106 -0
- package/payload/webchat/public/index.html +32 -0
- package/payload/webchat/public/style.css +161 -0
- package/payload/webchat/server.mjs +229 -0
- package/versions.json +5 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
--bg: #0f1115;
|
|
3
|
+
--panel: #171a21;
|
|
4
|
+
--user: #2563eb;
|
|
5
|
+
--assistant: #232733;
|
|
6
|
+
--tool: #14171e;
|
|
7
|
+
--tool-border: #2c3340;
|
|
8
|
+
--tool-err: #4a1f1f;
|
|
9
|
+
--text: #e7e9ee;
|
|
10
|
+
--muted: #8b93a5;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
* {
|
|
14
|
+
box-sizing: border-box;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
html,
|
|
18
|
+
body {
|
|
19
|
+
margin: 0;
|
|
20
|
+
height: 100%;
|
|
21
|
+
background: var(--bg);
|
|
22
|
+
color: var(--text);
|
|
23
|
+
font: 15px/1.45 -apple-system, system-ui, sans-serif;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
body {
|
|
27
|
+
display: flex;
|
|
28
|
+
flex-direction: column;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
#bar {
|
|
32
|
+
display: flex;
|
|
33
|
+
align-items: center;
|
|
34
|
+
justify-content: space-between;
|
|
35
|
+
padding: 10px 14px;
|
|
36
|
+
background: var(--panel);
|
|
37
|
+
border-bottom: 1px solid #00000044;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
#title {
|
|
41
|
+
font-weight: 600;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
#status {
|
|
45
|
+
font-size: 12px;
|
|
46
|
+
color: var(--muted);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
#status.on {
|
|
50
|
+
color: #34d399;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
#status.off {
|
|
54
|
+
color: #f59e0b;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
#messages {
|
|
58
|
+
flex: 1;
|
|
59
|
+
overflow-y: auto;
|
|
60
|
+
padding: 14px;
|
|
61
|
+
display: flex;
|
|
62
|
+
flex-direction: column;
|
|
63
|
+
gap: 8px;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.msg {
|
|
67
|
+
max-width: 84%;
|
|
68
|
+
padding: 8px 12px;
|
|
69
|
+
border-radius: 14px;
|
|
70
|
+
white-space: pre-wrap;
|
|
71
|
+
word-wrap: break-word;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.msg.user {
|
|
75
|
+
align-self: flex-end;
|
|
76
|
+
background: var(--user);
|
|
77
|
+
color: #fff;
|
|
78
|
+
border-bottom-right-radius: 4px;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.msg.assistant {
|
|
82
|
+
align-self: flex-start;
|
|
83
|
+
background: var(--assistant);
|
|
84
|
+
border-bottom-left-radius: 4px;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.msg.tool {
|
|
88
|
+
align-self: flex-start;
|
|
89
|
+
max-width: 92%;
|
|
90
|
+
background: var(--tool);
|
|
91
|
+
border: 1px solid var(--tool-border);
|
|
92
|
+
border-radius: 8px;
|
|
93
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
94
|
+
font-size: 12.5px;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
.msg.tool.error {
|
|
98
|
+
background: var(--tool-err);
|
|
99
|
+
border-color: #6b2a2a;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.tool-head {
|
|
103
|
+
display: flex;
|
|
104
|
+
align-items: center;
|
|
105
|
+
gap: 6px;
|
|
106
|
+
color: var(--muted);
|
|
107
|
+
margin-bottom: 4px;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.tool-head svg {
|
|
111
|
+
flex: none;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.tool-body {
|
|
115
|
+
margin: 0;
|
|
116
|
+
white-space: pre-wrap;
|
|
117
|
+
word-wrap: break-word;
|
|
118
|
+
max-height: 200px;
|
|
119
|
+
overflow: auto;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
#composer {
|
|
123
|
+
display: flex;
|
|
124
|
+
align-items: flex-end;
|
|
125
|
+
gap: 8px;
|
|
126
|
+
padding: 10px;
|
|
127
|
+
background: var(--panel);
|
|
128
|
+
border-top: 1px solid #00000044;
|
|
129
|
+
padding-bottom: calc(10px + env(safe-area-inset-bottom));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
#input {
|
|
133
|
+
flex: 1;
|
|
134
|
+
resize: none;
|
|
135
|
+
max-height: 140px;
|
|
136
|
+
padding: 10px 12px;
|
|
137
|
+
border-radius: 18px;
|
|
138
|
+
border: 1px solid var(--tool-border);
|
|
139
|
+
background: var(--bg);
|
|
140
|
+
color: var(--text);
|
|
141
|
+
font: inherit;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
#input:focus {
|
|
145
|
+
outline: none;
|
|
146
|
+
border-color: var(--user);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
#send {
|
|
150
|
+
flex: none;
|
|
151
|
+
width: 40px;
|
|
152
|
+
height: 40px;
|
|
153
|
+
border: none;
|
|
154
|
+
border-radius: 50%;
|
|
155
|
+
background: var(--user);
|
|
156
|
+
color: #fff;
|
|
157
|
+
display: grid;
|
|
158
|
+
place-items: center;
|
|
159
|
+
cursor: pointer;
|
|
160
|
+
}
|
|
161
|
+
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// maxy-lite structured web chat relay.
|
|
3
|
+
//
|
|
4
|
+
// Runs inside the proot-distro Ubuntu layer on the phone. It spawns the `claude`
|
|
5
|
+
// code binary in a node-pty (the sanctioned PTY-only runtime), delivers the
|
|
6
|
+
// browser's message by writing it into the pty, and renders replies by tailing
|
|
7
|
+
// the session JSONL — NOT by scraping terminal bytes. Parsed rows stream to the
|
|
8
|
+
// browser as structured user/assistant/tool messages over a WebSocket. Localhost
|
|
9
|
+
// only; off-device exposure (cloudflared) is out of scope here.
|
|
10
|
+
//
|
|
11
|
+
// Every per-turn step emits one structured `[lite-chat]` line to stderr,
|
|
12
|
+
// correlated by `turn=<n>`; `grep '\[lite-chat\]' <log> | grep 'turn=<n>'`
|
|
13
|
+
// reconstructs a turn end to end.
|
|
14
|
+
|
|
15
|
+
import http from 'node:http'
|
|
16
|
+
import fs from 'node:fs'
|
|
17
|
+
import path from 'node:path'
|
|
18
|
+
import os from 'node:os'
|
|
19
|
+
import { randomUUID } from 'node:crypto'
|
|
20
|
+
import { StringDecoder } from 'node:string_decoder'
|
|
21
|
+
import { fileURLToPath } from 'node:url'
|
|
22
|
+
import pty from 'node-pty'
|
|
23
|
+
import { WebSocketServer } from 'ws'
|
|
24
|
+
import { parseTranscript } from './parse-transcript.mjs'
|
|
25
|
+
|
|
26
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url))
|
|
27
|
+
const PUBLIC_DIR = path.join(HERE, 'public')
|
|
28
|
+
|
|
29
|
+
const PORT = Number(process.env.LITE_PORT || 7682)
|
|
30
|
+
const AGENT_HOME = process.env.LITE_AGENT_HOME || process.cwd()
|
|
31
|
+
const CLAUDE_BIN = process.env.LITE_CLAUDE_BIN || 'claude'
|
|
32
|
+
const PROJECTS_DIR = path.join(os.homedir(), '.claude', 'projects')
|
|
33
|
+
const STATE_DIR = process.env.LITE_STATE_DIR || path.join(AGENT_HOME, '.maxy-lite')
|
|
34
|
+
const SESSION_FILE = path.join(STATE_DIR, 'session-id')
|
|
35
|
+
const TAIL_MS = 250 // JSONL poll interval
|
|
36
|
+
const QUIET_MS = 1500 // silence that ends a turn for op=done
|
|
37
|
+
|
|
38
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
39
|
+
|
|
40
|
+
function log(op, fields = {}) {
|
|
41
|
+
const parts = [`[lite-chat] op=${op}`]
|
|
42
|
+
for (const [k, v] of Object.entries(fields)) parts.push(`${k}=${v}`)
|
|
43
|
+
process.stderr.write(parts.join(' ') + '\n')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ---- claude session: fresh (--session-id) or resumed (--resume) -------------
|
|
47
|
+
|
|
48
|
+
/** First existing `${dir}/${id}.jsonl` under PROJECTS_DIR, or null. */
|
|
49
|
+
function transcriptFor(id) {
|
|
50
|
+
let dirs
|
|
51
|
+
try {
|
|
52
|
+
dirs = fs.readdirSync(PROJECTS_DIR)
|
|
53
|
+
} catch {
|
|
54
|
+
return null
|
|
55
|
+
}
|
|
56
|
+
for (const d of dirs) {
|
|
57
|
+
const candidate = path.join(PROJECTS_DIR, d, `${id}.jsonl`)
|
|
58
|
+
if (fs.existsSync(candidate)) return candidate
|
|
59
|
+
}
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function resolveSession() {
|
|
64
|
+
try {
|
|
65
|
+
const saved = fs.readFileSync(SESSION_FILE, 'utf8').trim()
|
|
66
|
+
// Resume only when the saved session's transcript still exists; a persisted
|
|
67
|
+
// id whose JSONL was deleted (cache clear, proot reset) would make
|
|
68
|
+
// `claude --resume` exit and brick the relay, so fall through to a fresh one.
|
|
69
|
+
if (UUID_RE.test(saved) && transcriptFor(saved)) {
|
|
70
|
+
return { sessionId: saved, args: ['--resume', saved] }
|
|
71
|
+
}
|
|
72
|
+
} catch {
|
|
73
|
+
/* no persisted session: start fresh */
|
|
74
|
+
}
|
|
75
|
+
const sessionId = randomUUID()
|
|
76
|
+
fs.mkdirSync(STATE_DIR, { recursive: true })
|
|
77
|
+
fs.writeFileSync(SESSION_FILE, sessionId)
|
|
78
|
+
return { sessionId, args: ['--session-id', sessionId] }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const { sessionId, args } = resolveSession()
|
|
82
|
+
|
|
83
|
+
const child = pty.spawn(CLAUDE_BIN, args, {
|
|
84
|
+
name: 'xterm-256color',
|
|
85
|
+
cols: 120,
|
|
86
|
+
rows: 40,
|
|
87
|
+
cwd: AGENT_HOME,
|
|
88
|
+
env: process.env,
|
|
89
|
+
})
|
|
90
|
+
// The pty is input-only for our purposes; draining stdout prevents backpressure
|
|
91
|
+
// from stalling claude. Render comes from the JSONL, never these bytes.
|
|
92
|
+
child.onData(() => {})
|
|
93
|
+
child.onExit(({ exitCode }) => log('child-exit', { exitCode }))
|
|
94
|
+
log('spawn', { turn: 0, pty: true, pid: child.pid })
|
|
95
|
+
|
|
96
|
+
// ---- JSONL discovery + tail -------------------------------------------------
|
|
97
|
+
|
|
98
|
+
const allMessages = [] // full ordered transcript, replayed to every new client
|
|
99
|
+
let turn = 0 // increments per browser send
|
|
100
|
+
let turnActive = false
|
|
101
|
+
let turnSawGrowth = false // a turn is only "done-able" once its reply has begun
|
|
102
|
+
let turnStart = 0
|
|
103
|
+
let lastActivity = 0
|
|
104
|
+
|
|
105
|
+
let transcriptPath = null
|
|
106
|
+
let offset = 0
|
|
107
|
+
let residual = '' // partial trailing LINE carried across reads
|
|
108
|
+
const decoder = new StringDecoder('utf8') // carries a partial multibyte CHAR across reads
|
|
109
|
+
|
|
110
|
+
function tail() {
|
|
111
|
+
if (!transcriptPath) {
|
|
112
|
+
transcriptPath = transcriptFor(sessionId)
|
|
113
|
+
if (!transcriptPath) return
|
|
114
|
+
log('jsonl-found', { path: transcriptPath })
|
|
115
|
+
}
|
|
116
|
+
let size
|
|
117
|
+
try {
|
|
118
|
+
size = fs.statSync(transcriptPath).size
|
|
119
|
+
} catch {
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
if (size <= offset) return
|
|
123
|
+
const newBytes = size - offset
|
|
124
|
+
const fd = fs.openSync(transcriptPath, 'r')
|
|
125
|
+
const buf = Buffer.alloc(newBytes)
|
|
126
|
+
fs.readSync(fd, buf, 0, newBytes, offset)
|
|
127
|
+
fs.closeSync(fd)
|
|
128
|
+
offset = size
|
|
129
|
+
|
|
130
|
+
if (turnActive) {
|
|
131
|
+
log('jsonl', { turn, path: transcriptPath, newBytes })
|
|
132
|
+
turnSawGrowth = true
|
|
133
|
+
lastActivity = Date.now()
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// decoder.write buffers a multibyte char split across this read boundary, so a
|
|
137
|
+
// UTF-8 sequence straddling two tail reads is decoded whole, not corrupted.
|
|
138
|
+
residual += decoder.write(buf)
|
|
139
|
+
const lines = residual.split('\n')
|
|
140
|
+
residual = lines.pop() ?? '' // keep the trailing partial line for next read
|
|
141
|
+
|
|
142
|
+
const skips = []
|
|
143
|
+
const msgs = parseTranscript(lines, {
|
|
144
|
+
onSkip: (idx, reason) => skips.push({ idx, reason }),
|
|
145
|
+
})
|
|
146
|
+
for (const s of skips) log('parse-skip', { line: s.idx, reason: s.reason })
|
|
147
|
+
if (msgs.length === 0) return
|
|
148
|
+
|
|
149
|
+
allMessages.push(...msgs)
|
|
150
|
+
if (turnActive) {
|
|
151
|
+
const kinds = [...new Set(msgs.map((m) => m.kind))].join('|')
|
|
152
|
+
log('render', { turn, msgs: msgs.length, kinds: `[${kinds}]` })
|
|
153
|
+
}
|
|
154
|
+
broadcast({ type: 'append', messages: msgs })
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
setInterval(tail, TAIL_MS)
|
|
158
|
+
|
|
159
|
+
// A turn ends when the JSONL has been quiet for QUIET_MS after the last growth.
|
|
160
|
+
// Gated on turnSawGrowth so a slow first JSONL write (cold start, discovery
|
|
161
|
+
// latency) cannot mark the turn done before any reply has rendered.
|
|
162
|
+
setInterval(() => {
|
|
163
|
+
if (turnActive && turnSawGrowth && Date.now() - lastActivity > QUIET_MS) {
|
|
164
|
+
turnActive = false
|
|
165
|
+
log('done', { turn, ranMs: Date.now() - turnStart })
|
|
166
|
+
}
|
|
167
|
+
}, 500)
|
|
168
|
+
|
|
169
|
+
// ---- transport: HTTP static + WebSocket -------------------------------------
|
|
170
|
+
|
|
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' }
|
|
173
|
+
|
|
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
|
+
})
|
|
191
|
+
|
|
192
|
+
const wss = new WebSocketServer({ server, path: '/ws' })
|
|
193
|
+
|
|
194
|
+
function broadcast(obj) {
|
|
195
|
+
const payload = JSON.stringify(obj)
|
|
196
|
+
for (const c of wss.clients) {
|
|
197
|
+
if (c.readyState === 1) c.send(payload)
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
wss.on('connection', (ws) => {
|
|
202
|
+
// Reload resumes the transcript: every new client gets the full snapshot.
|
|
203
|
+
ws.send(JSON.stringify({ type: 'snapshot', messages: allMessages }))
|
|
204
|
+
ws.on('message', (raw) => {
|
|
205
|
+
let m
|
|
206
|
+
try {
|
|
207
|
+
m = JSON.parse(raw.toString())
|
|
208
|
+
} catch {
|
|
209
|
+
return
|
|
210
|
+
}
|
|
211
|
+
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
|
+
// 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 })
|
|
224
|
+
})
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
server.listen(PORT, '127.0.0.1', () => {
|
|
228
|
+
log('listening', { port: PORT, sessionId, home: AGENT_HOME })
|
|
229
|
+
})
|