@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.
@@ -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
+ })
package/versions.json ADDED
@@ -0,0 +1,5 @@
1
+ {
2
+ "nodeMajor": "20",
3
+ "claudeCode": "2.1.185",
4
+ "ttyd": "1.7.7"
5
+ }