@sma1lboy/rove 0.8.89
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 +60 -0
- package/dist/cli/index.js +959 -0
- package/dist/cli/kobe.js +959 -0
- package/dist/cli/pty-host-node.mjs +1015 -0
- package/dist/cli/rove.js +959 -0
- package/dist/pulse-n3cq1btw.wav +0 -0
- package/dist/skills/kobe/SKILL.md +317 -0
- package/dist/web-ui/assets/AppShell-BTjvuY-t.js +5 -0
- package/dist/web-ui/assets/AppShell-HiyATJse.css +1 -0
- package/dist/web-ui/assets/Board-DPB4h6mJ.js +1 -0
- package/dist/web-ui/assets/BoardPeek-69VJptGV.js +2 -0
- package/dist/web-ui/assets/ChatTerminal-BrP-ENHg.css +1 -0
- package/dist/web-ui/assets/ChatTerminal-MCI1aBbT.js +144 -0
- package/dist/web-ui/assets/ChatTranscript-DLthbnmB.js +1 -0
- package/dist/web-ui/assets/ConfirmDialog-CDPOtXww.js +1 -0
- package/dist/web-ui/assets/IssueIntakePanel-XYsIcIhp.js +1 -0
- package/dist/web-ui/assets/IssuePeek-CP_rAkqO.js +1 -0
- package/dist/web-ui/assets/IssuesPage-BR5mpuo-.js +1 -0
- package/dist/web-ui/assets/RichEditor-B4sEOPyN.css +1 -0
- package/dist/web-ui/assets/RichEditor-DJFl5TvH.js +173 -0
- package/dist/web-ui/assets/SettingsPage-CLRhr-sg.js +2 -0
- package/dist/web-ui/assets/SlideOver-Dxtk6Gn2.js +1 -0
- package/dist/web-ui/assets/ViewToggle-BdPCMBWa.js +1 -0
- package/dist/web-ui/assets/WorkspaceTabs-CMKlvN_Y.js +3 -0
- package/dist/web-ui/assets/WorkspaceTabs-DLzL-Tad.css +1 -0
- package/dist/web-ui/assets/active-task-GeQZqbJK.js +1 -0
- package/dist/web-ui/assets/activity-BjTd1D8q.js +1 -0
- package/dist/web-ui/assets/arrow-left-DNM2CHhS.js +1 -0
- package/dist/web-ui/assets/board-CGiK0u0M.js +1 -0
- package/dist/web-ui/assets/chips-Da37C4KZ.js +1 -0
- package/dist/web-ui/assets/columns-3-CQGAnX-4.js +1 -0
- package/dist/web-ui/assets/createLucideIcon-CM5xLVj1.js +1 -0
- package/dist/web-ui/assets/desktop-BThq9eSR.js +2 -0
- package/dist/web-ui/assets/engines-Cnxy1hW9.js +1 -0
- package/dist/web-ui/assets/external-link-BwT2u1hD.js +1 -0
- package/dist/web-ui/assets/external-store-DJlWpyYC.js +1 -0
- package/dist/web-ui/assets/harness-q9gT7Jic.js +1 -0
- package/dist/web-ui/assets/index-CgL6oDIY.js +10 -0
- package/dist/web-ui/assets/index-MuA3NLRu.css +1 -0
- package/dist/web-ui/assets/issues-1SCRkcB0.js +4 -0
- package/dist/web-ui/assets/issues-BKkua1D9.js +1 -0
- package/dist/web-ui/assets/jetbrains-mono-latin-400-normal-6-qcROiO.woff +0 -0
- package/dist/web-ui/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
- package/dist/web-ui/assets/jetbrains-mono-latin-500-normal-BWZEU5yA.woff2 +0 -0
- package/dist/web-ui/assets/jetbrains-mono-latin-500-normal-CJOVTJB7.woff +0 -0
- package/dist/web-ui/assets/jetbrains-mono-latin-600-normal-BfsvjouI.woff +0 -0
- package/dist/web-ui/assets/jetbrains-mono-latin-600-normal-C8RAYTDA.woff2 +0 -0
- package/dist/web-ui/assets/jetbrains-mono-latin-700-normal-BYuf6tUa.woff2 +0 -0
- package/dist/web-ui/assets/jetbrains-mono-latin-700-normal-D3wTyLJW.woff +0 -0
- package/dist/web-ui/assets/lazy-issue-panels-0Rhl2uhh.js +6 -0
- package/dist/web-ui/assets/plus-BbX5qXba.js +1 -0
- package/dist/web-ui/assets/react-dom-DCnDkJvp.js +1 -0
- package/dist/web-ui/assets/rotate-cw-D-UUtiD0.js +1 -0
- package/dist/web-ui/assets/routes-SBb-uSy8.js +1 -0
- package/dist/web-ui/assets/search-BxbFZoup.js +1 -0
- package/dist/web-ui/assets/settings-tPcmEHCP.js +1 -0
- package/dist/web-ui/assets/store-uT65Mask.js +2 -0
- package/dist/web-ui/assets/tabs-Dx-TnbvG.js +1 -0
- package/dist/web-ui/assets/task._taskId-BlZ0vG_1.js +2 -0
- package/dist/web-ui/assets/task._taskId-BmObFoVZ.js +1 -0
- package/dist/web-ui/assets/terminal-qBEkzc-m.js +1 -0
- package/dist/web-ui/assets/text-match-DW3sD-Rt.js +1 -0
- package/dist/web-ui/assets/time-kviT26Tm.js +1 -0
- package/dist/web-ui/assets/toast-BXjAT0ix.js +1 -0
- package/dist/web-ui/assets/triage-7QLsCY8d.js +1 -0
- package/dist/web-ui/assets/use-focus-trap-DNMrlNR0.js +1 -0
- package/dist/web-ui/assets/vendor-CdtpTJrf.js +1 -0
- package/dist/web-ui/assets/web-transport-D0bugxxv.js +1 -0
- package/dist/web-ui/assets/with-selector-B5R3h-Ow.js +1 -0
- package/dist/web-ui/assets/worktrees-C_XKA7jM.js +1 -0
- package/dist/web-ui/assets/x-7cB9X7Hi.js +1 -0
- package/dist/web-ui/favicon.ico +0 -0
- package/dist/web-ui/index.html +35 -0
- package/dist/web-ui/logo192.png +0 -0
- package/dist/web-ui/logo512.png +0 -0
- package/dist/web-ui/manifest.json +25 -0
- package/dist/web-ui/origin-policy.mjs +41 -0
- package/dist/web-ui/pty-scrollback.mjs +51 -0
- package/dist/web-ui/pty-server.mjs +212 -0
- package/dist/web-ui/pty-session-lifecycle.mjs +282 -0
- package/dist/web-ui/robots.txt +3 -0
- package/package.json +94 -0
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PTY server — the node half of the web terminal (node-pty doesn't work under
|
|
3
|
+
* bun, so the live terminals run here as a separate node process).
|
|
4
|
+
*
|
|
5
|
+
* Model: each web PTY tab is identified by a client-generated `tab` id. Its
|
|
6
|
+
* PTY is spawned lazily on first attach (launch spec fetched from daemon web
|
|
7
|
+
* transport by taskId + mode) and kept alive across WebSocket reconnects, so a
|
|
8
|
+
* page refresh re-attaches to the same process. Closing a tab (POST
|
|
9
|
+
* /pty/close) kills its PTY.
|
|
10
|
+
*
|
|
11
|
+
* ws /pty?tab=<id>&taskId=<id>&mode=engine|shell&cols=<n>&rows=<n>
|
|
12
|
+
* POST /pty/close { tab } kill the tab process
|
|
13
|
+
* POST /pty/send { tab, taskId, text } paste text + Enter into the tab's engine
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { createServer } from "node:http"
|
|
17
|
+
import { spawn } from "node-pty"
|
|
18
|
+
import { WebSocketServer } from "ws"
|
|
19
|
+
import { allowedHostForBindHost, originAllowed } from "./origin-policy.mjs"
|
|
20
|
+
import { ptyEnv } from "./pty-env.mjs"
|
|
21
|
+
import { createScrollback } from "./pty-scrollback.mjs"
|
|
22
|
+
import { createPtySessionManager } from "./pty-session-lifecycle.mjs"
|
|
23
|
+
|
|
24
|
+
const PORT = Number.parseInt(process.env.KOBE_PTY_PORT ?? "5175", 10)
|
|
25
|
+
const DAEMON_WEB_PORT = Number.parseInt(process.env.KOBE_DAEMON_WEB_PORT ?? "45174", 10)
|
|
26
|
+
const SCROLLBACK_CAP = 256 * 1024 // bytes of recent output replayed on (re)attach
|
|
27
|
+
const HEALTH_PATH = "/__kobe_web"
|
|
28
|
+
const HEALTH_MARKER = "kobe-web"
|
|
29
|
+
const HOST = process.env.KOBE_WEB_HOST?.trim() || "127.0.0.1"
|
|
30
|
+
const ALLOWED_HOST = allowedHostForBindHost(HOST)
|
|
31
|
+
|
|
32
|
+
async function fetchSpec(taskId, mode) {
|
|
33
|
+
// e2e/dev harness override: run an arbitrary TUI (dev:mock / dev:sandbox) in
|
|
34
|
+
// the PTY instead of resolving a task's engine via the daemon — so a Playwright
|
|
35
|
+
// test can drive the real TUI through the web terminal with no daemon or task.
|
|
36
|
+
if (process.env.KOBE_PTY_DEV_COMMAND) {
|
|
37
|
+
return {
|
|
38
|
+
cwd: process.env.KOBE_PTY_DEV_CWD ?? process.cwd(),
|
|
39
|
+
command: ["/bin/sh", "-lc", process.env.KOBE_PTY_DEV_COMMAND],
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const path = mode === "shell" ? "/api/terminal-spec" : "/api/engine-spec"
|
|
43
|
+
const res = await fetch(`http://localhost:${DAEMON_WEB_PORT}${path}?taskId=${encodeURIComponent(taskId)}`)
|
|
44
|
+
const json = await res.json()
|
|
45
|
+
if (!res.ok || json.error) throw new Error(json.error ?? `engine-spec failed (${res.status})`)
|
|
46
|
+
return json // { cwd, command: string[] }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const ptySessions = createPtySessionManager({
|
|
50
|
+
fetchSpec,
|
|
51
|
+
spawnPty: spawn,
|
|
52
|
+
createScrollback,
|
|
53
|
+
scrollbackCap: SCROLLBACK_CAP,
|
|
54
|
+
env: ptyEnv,
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
const server = createServer((req, res) => {
|
|
58
|
+
const url = new URL(req.url ?? "/", "http://localhost")
|
|
59
|
+
if (url.pathname === HEALTH_PATH) {
|
|
60
|
+
res.writeHead(200, { "content-type": "text/plain" })
|
|
61
|
+
res.end(HEALTH_MARKER)
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
if (req.method === "OPTIONS") {
|
|
65
|
+
res.writeHead(204, {
|
|
66
|
+
"access-control-allow-origin": "*",
|
|
67
|
+
"access-control-allow-methods": "POST, OPTIONS",
|
|
68
|
+
"access-control-allow-headers": "content-type",
|
|
69
|
+
})
|
|
70
|
+
res.end()
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
if (req.method === "POST" && url.pathname === "/pty/send") {
|
|
74
|
+
// Sending text DRIVES the engine like a keyboard, so unlike /pty/close
|
|
75
|
+
// (best-effort kill) this holds the same origin policy as the WS attach:
|
|
76
|
+
// localhost pages or non-browser clients only.
|
|
77
|
+
if (!originAllowed(req.headers.origin, { allowedHost: ALLOWED_HOST })) {
|
|
78
|
+
res.writeHead(403)
|
|
79
|
+
res.end()
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
let body = ""
|
|
83
|
+
req.on("data", (c) => {
|
|
84
|
+
body += c
|
|
85
|
+
})
|
|
86
|
+
req.on("end", async () => {
|
|
87
|
+
let tab
|
|
88
|
+
let taskId
|
|
89
|
+
let text
|
|
90
|
+
try {
|
|
91
|
+
;({ tab, taskId, text } = JSON.parse(body || "{}"))
|
|
92
|
+
} catch {
|
|
93
|
+
/* ignore */
|
|
94
|
+
}
|
|
95
|
+
const respond = (status, payload) => {
|
|
96
|
+
res.writeHead(status, {
|
|
97
|
+
"content-type": "application/json",
|
|
98
|
+
"access-control-allow-origin": "*",
|
|
99
|
+
})
|
|
100
|
+
res.end(JSON.stringify(payload))
|
|
101
|
+
}
|
|
102
|
+
if (typeof tab !== "string" || !tab || typeof text !== "string" || !text) {
|
|
103
|
+
respond(400, { sent: false, error: "tab and text are required" })
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
let result
|
|
107
|
+
try {
|
|
108
|
+
// Spawn-on-send: a board action can fire without the terminal ever
|
|
109
|
+
// opening — output lands in the scrollback ring for the next attach.
|
|
110
|
+
result = await ptySessions.sendText({
|
|
111
|
+
tabId: tab,
|
|
112
|
+
taskId: typeof taskId === "string" && taskId ? taskId : null,
|
|
113
|
+
text,
|
|
114
|
+
})
|
|
115
|
+
} catch (err) {
|
|
116
|
+
respond(500, { sent: false, error: `failed to start engine: ${err?.message ?? err}` })
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
if (!result.sent) {
|
|
120
|
+
respond(404, { sent: false, error: "no such tab" })
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
respond(200, { sent: true, spawned: result.spawned })
|
|
124
|
+
})
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
if (req.method === "POST" && url.pathname === "/pty/close") {
|
|
128
|
+
// Killing a tab is a side effect a cross-origin local page could abuse to
|
|
129
|
+
// DoS the session (tab ids are client-generated/observable), so hold the
|
|
130
|
+
// same origin policy as /pty/send and the WS attach: localhost pages or
|
|
131
|
+
// non-browser clients (no Origin) only.
|
|
132
|
+
if (!originAllowed(req.headers.origin, { allowedHost: ALLOWED_HOST })) {
|
|
133
|
+
res.writeHead(403)
|
|
134
|
+
res.end()
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
let body = ""
|
|
138
|
+
req.on("data", (c) => {
|
|
139
|
+
body += c
|
|
140
|
+
})
|
|
141
|
+
req.on("end", () => {
|
|
142
|
+
let tab
|
|
143
|
+
try {
|
|
144
|
+
tab = JSON.parse(body || "{}").tab
|
|
145
|
+
} catch {
|
|
146
|
+
/* ignore */
|
|
147
|
+
}
|
|
148
|
+
const ok = tab ? ptySessions.closeSession(tab) : false
|
|
149
|
+
res.writeHead(200, {
|
|
150
|
+
"content-type": "application/json",
|
|
151
|
+
"access-control-allow-origin": "*",
|
|
152
|
+
})
|
|
153
|
+
res.end(JSON.stringify({ closed: ok }))
|
|
154
|
+
})
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
res.writeHead(404)
|
|
158
|
+
res.end()
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
// A PTY WS is arbitrary command exec in the worktree, so reject cross-origin
|
|
162
|
+
// upgrades: a browser sends an Origin header, and only loopback pages (or the
|
|
163
|
+
// deliberately configured LAN host) may attach. This defends a malicious local
|
|
164
|
+
// page / DNS-rebinding even on the loopback bind. Non-browser clients (no
|
|
165
|
+
// Origin) are allowed — there's no browser to forge their request.
|
|
166
|
+
const wss = new WebSocketServer({
|
|
167
|
+
server,
|
|
168
|
+
path: "/pty",
|
|
169
|
+
verifyClient: ({ origin }) => originAllowed(origin, { allowedHost: ALLOWED_HOST }),
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
wss.on("connection", (ws, req) => {
|
|
173
|
+
const url = new URL(req.url ?? "/", "http://localhost")
|
|
174
|
+
const tabId = url.searchParams.get("tab")
|
|
175
|
+
const taskId = url.searchParams.get("taskId")
|
|
176
|
+
const cols = Number.parseInt(url.searchParams.get("cols") ?? "80", 10) || 80
|
|
177
|
+
const rows = Number.parseInt(url.searchParams.get("rows") ?? "24", 10) || 24
|
|
178
|
+
const mode = url.searchParams.get("mode") === "shell" ? "shell" : "engine"
|
|
179
|
+
|
|
180
|
+
if (!tabId || !taskId) {
|
|
181
|
+
ws.close(1008, "missing tab/taskId")
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
void (async () => {
|
|
186
|
+
try {
|
|
187
|
+
// Single-flight spawn: concurrent attaches for this tab share one PTY.
|
|
188
|
+
await ptySessions.attachSocket({ ws, tabId, taskId, mode, cols, rows })
|
|
189
|
+
} catch (err) {
|
|
190
|
+
if (ws.readyState === ws.OPEN) {
|
|
191
|
+
ws.send(`\r\nfailed to start ${mode}: ${err?.message ?? err}\r\n`)
|
|
192
|
+
ws.close(1011, "spawn failed")
|
|
193
|
+
}
|
|
194
|
+
return
|
|
195
|
+
}
|
|
196
|
+
})()
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
// Bind loopback by default — a PTY is an arbitrary shell/engine in the
|
|
200
|
+
// worktree, so it must never listen on all interfaces. KOBE_WEB_HOST overrides.
|
|
201
|
+
server.listen(PORT, HOST, () => {
|
|
202
|
+
process.stdout.write(`kobe pty-server listening on ${HOST}:${PORT} (daemon-web :${DAEMON_WEB_PORT})\n`)
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
const shutdown = () => {
|
|
206
|
+
ptySessions.shutdown()
|
|
207
|
+
wss.close()
|
|
208
|
+
server.close()
|
|
209
|
+
process.exit(0)
|
|
210
|
+
}
|
|
211
|
+
process.on("SIGINT", shutdown)
|
|
212
|
+
process.on("SIGTERM", shutdown)
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PTY session lifecycle manager for the web sidecar.
|
|
3
|
+
*
|
|
4
|
+
* The HTTP/WebSocket server is transport glue; this module owns the deeper
|
|
5
|
+
* lifecycle state for tab-keyed PTY sessions: single-flight spawn, attach,
|
|
6
|
+
* scrollback replay, resize/input, close, process-exit cleanup, and shutdown.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const DEFAULT_SUBMIT_DELAYS = {
|
|
10
|
+
spawnedPasteMs: 2500,
|
|
11
|
+
existingPasteMs: 0,
|
|
12
|
+
enterMs: 150,
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// A browser opening /pty?tab=<id> with ever-new tab ids would otherwise spawn
|
|
16
|
+
// node-pty processes without bound. Cap concurrent sessions; spawning past the
|
|
17
|
+
// cap evicts the oldest session with no attached sockets, and rejects when every
|
|
18
|
+
// session is being actively viewed.
|
|
19
|
+
const DEFAULT_MAX_SESSIONS = 64
|
|
20
|
+
|
|
21
|
+
// PTY→WebSocket backpressure: a flooding pty (`yes`) outruns a slow browser, so
|
|
22
|
+
// node buffers the unsent bytes (ws.bufferedAmount) without bound. Pause the pty
|
|
23
|
+
// once any socket's buffer crosses the high-water mark and resume once every
|
|
24
|
+
// socket has drained back under the low-water mark.
|
|
25
|
+
const DEFAULT_BACKPRESSURE = {
|
|
26
|
+
highWaterBytes: 1 << 20, // 1 MiB queued to a socket → pause the pty
|
|
27
|
+
lowWaterBytes: 1 << 18, // 256 KiB → resume once all sockets are back under
|
|
28
|
+
drainPollMs: 50,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** First session (insertion order) with no attached sockets — the safest to
|
|
32
|
+
* evict under cap pressure since no browser is watching it. Returns null when
|
|
33
|
+
* every session is actively viewed (caller rejects the new spawn). Exported
|
|
34
|
+
* for tests. */
|
|
35
|
+
export function pickEvictableTab(sessions) {
|
|
36
|
+
for (const [tabId, entry] of sessions) {
|
|
37
|
+
if (entry.sockets.size === 0) return tabId
|
|
38
|
+
}
|
|
39
|
+
return null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Pause the pty when ANY open socket has more than `highWaterBytes` queued —
|
|
43
|
+
* one slow client is enough to grow node memory unbounded. Exported for tests. */
|
|
44
|
+
export function shouldPausePty(sockets, highWaterBytes) {
|
|
45
|
+
for (const ws of sockets) {
|
|
46
|
+
if (ws.readyState === ws.OPEN && ws.bufferedAmount > highWaterBytes) {
|
|
47
|
+
return true
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return false
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Resume only when EVERY open socket has drained back under `lowWaterBytes`
|
|
54
|
+
* (an empty socket set resumes immediately). Exported for tests. */
|
|
55
|
+
export function shouldResumePty(sockets, lowWaterBytes) {
|
|
56
|
+
for (const ws of sockets) {
|
|
57
|
+
if (ws.readyState === ws.OPEN && ws.bufferedAmount > lowWaterBytes) {
|
|
58
|
+
return false
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return true
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function createPtySessionManager({
|
|
65
|
+
fetchSpec,
|
|
66
|
+
spawnPty,
|
|
67
|
+
createScrollback,
|
|
68
|
+
scrollbackCap,
|
|
69
|
+
env,
|
|
70
|
+
setTimeoutFn = setTimeout,
|
|
71
|
+
setIntervalFn = setInterval,
|
|
72
|
+
clearIntervalFn = clearInterval,
|
|
73
|
+
submitDelays = DEFAULT_SUBMIT_DELAYS,
|
|
74
|
+
maxSessions = DEFAULT_MAX_SESSIONS,
|
|
75
|
+
backpressure = DEFAULT_BACKPRESSURE,
|
|
76
|
+
}) {
|
|
77
|
+
/** @type {Map<string, { pty: any, scrollback: ReturnType<createScrollback>, sockets: Set<any> }>} */
|
|
78
|
+
const sessions = new Map()
|
|
79
|
+
/** @type {Map<string, Promise<any>>} */
|
|
80
|
+
const pendingSpawns = new Map()
|
|
81
|
+
|
|
82
|
+
/** Pause the pty once a socket is saturated and poll for drain to resume.
|
|
83
|
+
* Idempotent: a second saturation while already paused is a no-op. */
|
|
84
|
+
function applyBackpressure(entry) {
|
|
85
|
+
if (entry.paused) return
|
|
86
|
+
if (!shouldPausePty(entry.sockets, backpressure.highWaterBytes)) return
|
|
87
|
+
entry.paused = true
|
|
88
|
+
try {
|
|
89
|
+
entry.pty.pause?.()
|
|
90
|
+
} catch {
|
|
91
|
+
/* engine died — onExit clears the entry */
|
|
92
|
+
}
|
|
93
|
+
if (entry.drainTimer !== null) return
|
|
94
|
+
entry.drainTimer = setIntervalFn(() => {
|
|
95
|
+
if (!shouldResumePty(entry.sockets, backpressure.lowWaterBytes)) return
|
|
96
|
+
clearDrainTimer(entry)
|
|
97
|
+
entry.paused = false
|
|
98
|
+
try {
|
|
99
|
+
entry.pty.resume?.()
|
|
100
|
+
} catch {
|
|
101
|
+
/* engine died — onExit clears the entry */
|
|
102
|
+
}
|
|
103
|
+
}, backpressure.drainPollMs)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function clearDrainTimer(entry) {
|
|
107
|
+
if (entry.drainTimer !== null) {
|
|
108
|
+
clearIntervalFn(entry.drainTimer)
|
|
109
|
+
entry.drainTimer = null
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function spawnSession(tabId, spec, cols, rows) {
|
|
114
|
+
// Enforce the session cap before allocating another process: evict the
|
|
115
|
+
// oldest unwatched session, or reject when every session is in active use.
|
|
116
|
+
if (sessions.size >= maxSessions && !sessions.has(tabId)) {
|
|
117
|
+
const victim = pickEvictableTab(sessions)
|
|
118
|
+
if (victim === null) {
|
|
119
|
+
throw new Error(`pty session limit reached (${maxSessions})`)
|
|
120
|
+
}
|
|
121
|
+
closeSession(victim)
|
|
122
|
+
}
|
|
123
|
+
const [cmd, ...args] = spec.command
|
|
124
|
+
const spawnEnv = typeof env === "function" ? env() : env
|
|
125
|
+
let pty
|
|
126
|
+
try {
|
|
127
|
+
pty = spawnPty(cmd, args, {
|
|
128
|
+
name: "xterm-256color",
|
|
129
|
+
cols,
|
|
130
|
+
rows,
|
|
131
|
+
cwd: spec.cwd,
|
|
132
|
+
env: spawnEnv,
|
|
133
|
+
})
|
|
134
|
+
} catch (error) {
|
|
135
|
+
const path = typeof spawnEnv.PATH === "string" && spawnEnv.PATH ? "set" : "missing"
|
|
136
|
+
throw new Error(`PTY spawn failed (command ${cmd}, cwd ${spec.cwd}, PATH ${path}): ${error instanceof Error ? error.message : String(error)}`)
|
|
137
|
+
}
|
|
138
|
+
const entry = {
|
|
139
|
+
pty,
|
|
140
|
+
scrollback: createScrollback(scrollbackCap),
|
|
141
|
+
sockets: new Set(),
|
|
142
|
+
paused: false,
|
|
143
|
+
drainTimer: null,
|
|
144
|
+
}
|
|
145
|
+
pty.onData((data) => {
|
|
146
|
+
entry.scrollback.push(data)
|
|
147
|
+
for (const ws of entry.sockets) {
|
|
148
|
+
if (ws.readyState === ws.OPEN) ws.send(data)
|
|
149
|
+
}
|
|
150
|
+
applyBackpressure(entry)
|
|
151
|
+
})
|
|
152
|
+
pty.onExit(() => {
|
|
153
|
+
clearDrainTimer(entry)
|
|
154
|
+
for (const ws of entry.sockets) {
|
|
155
|
+
if (ws.readyState === ws.OPEN) ws.close(1000, "engine exited")
|
|
156
|
+
}
|
|
157
|
+
if (sessions.get(tabId) === entry) sessions.delete(tabId)
|
|
158
|
+
})
|
|
159
|
+
sessions.set(tabId, entry)
|
|
160
|
+
return entry
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function ensureSession(tabId, taskId, mode, cols, rows) {
|
|
164
|
+
const existing = sessions.get(tabId)
|
|
165
|
+
if (existing) return existing
|
|
166
|
+
const inflight = pendingSpawns.get(tabId)
|
|
167
|
+
if (inflight) return inflight
|
|
168
|
+
const p = (async () => {
|
|
169
|
+
const spec = await fetchSpec(taskId, mode)
|
|
170
|
+
return sessions.get(tabId) ?? spawnSession(tabId, spec, cols, rows)
|
|
171
|
+
})()
|
|
172
|
+
pendingSpawns.set(tabId, p)
|
|
173
|
+
try {
|
|
174
|
+
return await p
|
|
175
|
+
} finally {
|
|
176
|
+
pendingSpawns.delete(tabId)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function safePty(tabId, entry, fn) {
|
|
181
|
+
if (sessions.get(tabId) !== entry) return
|
|
182
|
+
try {
|
|
183
|
+
fn(entry.pty)
|
|
184
|
+
} catch {
|
|
185
|
+
/* engine died — its onExit closes sockets and clears the entry */
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function attachSocket({ ws, tabId, taskId, mode, cols, rows }) {
|
|
190
|
+
const entry = await ensureSession(tabId, taskId, mode, cols, rows)
|
|
191
|
+
const replay = entry.scrollback.length() > 0 ? entry.scrollback.replay() : ""
|
|
192
|
+
entry.sockets.add(ws)
|
|
193
|
+
if (replay && ws.readyState === ws.OPEN) ws.send(replay)
|
|
194
|
+
safePty(tabId, entry, (pty) => pty.resize(cols, rows))
|
|
195
|
+
|
|
196
|
+
ws.on("message", (raw) => {
|
|
197
|
+
const text = raw.toString()
|
|
198
|
+
if (text.startsWith("{")) {
|
|
199
|
+
try {
|
|
200
|
+
const msg = JSON.parse(text)
|
|
201
|
+
if (msg && msg.type === "resize" && Number.isFinite(msg.cols) && Number.isFinite(msg.rows)) {
|
|
202
|
+
safePty(tabId, entry, (pty) => pty.resize(Math.max(1, msg.cols | 0), Math.max(1, msg.rows | 0)))
|
|
203
|
+
return
|
|
204
|
+
}
|
|
205
|
+
} catch {
|
|
206
|
+
/* fall through to raw write */
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
safePty(tabId, entry, (pty) => pty.write(text))
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
ws.on("close", () => {
|
|
213
|
+
entry.sockets.delete(ws)
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
return entry
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function closeSession(tabId) {
|
|
220
|
+
const entry = sessions.get(tabId)
|
|
221
|
+
if (!entry) return false
|
|
222
|
+
clearDrainTimer(entry)
|
|
223
|
+
try {
|
|
224
|
+
entry.pty.kill()
|
|
225
|
+
} catch {
|
|
226
|
+
/* already gone */
|
|
227
|
+
}
|
|
228
|
+
if (sessions.get(tabId) === entry) sessions.delete(tabId)
|
|
229
|
+
return true
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function sendText({ tabId, taskId, text }) {
|
|
233
|
+
let entry = sessions.get(tabId)
|
|
234
|
+
let spawned = false
|
|
235
|
+
if (!entry && taskId) {
|
|
236
|
+
entry = await ensureSession(tabId, taskId, "engine", 80, 24)
|
|
237
|
+
spawned = true
|
|
238
|
+
}
|
|
239
|
+
if (!entry) return { sent: false, spawned: false, missing: true }
|
|
240
|
+
|
|
241
|
+
const target = entry
|
|
242
|
+
const pasteDelay = spawned ? submitDelays.spawnedPasteMs : submitDelays.existingPasteMs
|
|
243
|
+
setTimeoutFn(() => {
|
|
244
|
+
try {
|
|
245
|
+
target.pty.write(`\x1b[200~${text}\x1b[201~`)
|
|
246
|
+
} catch {
|
|
247
|
+
return
|
|
248
|
+
}
|
|
249
|
+
setTimeoutFn(() => {
|
|
250
|
+
try {
|
|
251
|
+
target.pty.write("\r")
|
|
252
|
+
} catch {
|
|
253
|
+
/* best-effort */
|
|
254
|
+
}
|
|
255
|
+
}, submitDelays.enterMs)
|
|
256
|
+
}, pasteDelay)
|
|
257
|
+
return { sent: true, spawned }
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function shutdown() {
|
|
261
|
+
for (const entry of sessions.values()) {
|
|
262
|
+
clearDrainTimer(entry)
|
|
263
|
+
try {
|
|
264
|
+
entry.pty.kill()
|
|
265
|
+
} catch {
|
|
266
|
+
/* ignore */
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
sessions.clear()
|
|
270
|
+
pendingSpawns.clear()
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
attachSocket,
|
|
275
|
+
closeSession,
|
|
276
|
+
ensureSession,
|
|
277
|
+
sendText,
|
|
278
|
+
shutdown,
|
|
279
|
+
sessionCount: () => sessions.size,
|
|
280
|
+
pendingSpawnCount: () => pendingSpawns.size,
|
|
281
|
+
}
|
|
282
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json.schemastore.org/package.json",
|
|
3
|
+
"name": "@sma1lboy/rove",
|
|
4
|
+
"version": "0.8.89",
|
|
5
|
+
"description": "Rove — the terminal-native agent multiplexer",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"packageManager": "bun@1.3.13",
|
|
8
|
+
"bin": {
|
|
9
|
+
"kobe": "dist/cli/kobe.js",
|
|
10
|
+
"rove": "dist/cli/rove.js"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"dist/cli",
|
|
14
|
+
"dist/web-ui",
|
|
15
|
+
"dist/skills",
|
|
16
|
+
"dist/*.wav",
|
|
17
|
+
"README.md",
|
|
18
|
+
"LICENSE"
|
|
19
|
+
],
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "git+https://github.com/Sma1lboy/rove.git"
|
|
26
|
+
},
|
|
27
|
+
"homepage": "https://github.com/Sma1lboy/rove#readme",
|
|
28
|
+
"bugs": {
|
|
29
|
+
"url": "https://github.com/Sma1lboy/rove/issues"
|
|
30
|
+
},
|
|
31
|
+
"engines": {
|
|
32
|
+
"bun": ">=1.3.11"
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"dev": "KOBE_DEV=1 bun --conditions=browser ./src/cli/kobe.ts",
|
|
36
|
+
"dev:rove": "ROVE_DEV=1 bun --conditions=browser ./src/cli/rove.ts",
|
|
37
|
+
"dev:mock": "KOBE_DEV=1 bun ./src/tui-react/mock/host.tsx",
|
|
38
|
+
"dev:mock-react": "KOBE_DEV=1 bun ./src/tui-react/mock/host.tsx",
|
|
39
|
+
"dev:mock-react-task-dialogs": "KOBE_DEV=1 bun ./src/tui-react/component/mock-dialogs-host.tsx",
|
|
40
|
+
"dev:mock-react-filetree": "KOBE_DEV=1 bun ./src/tui-react/panes/filetree/mock-host.tsx",
|
|
41
|
+
"dev:mock-react-sidebar": "KOBE_DEV=1 bun ./src/tui-react/panes/sidebar/mock-host.tsx",
|
|
42
|
+
"dev:mock-react-terminal": "KOBE_DEV=1 bun ./src/tui-react/panes/terminal/mock-host.tsx",
|
|
43
|
+
"dev:mock-react-dialogs": "KOBE_DEV=1 bun ./src/tui-react/mock/dialogs-host.tsx",
|
|
44
|
+
"dev:mock-react-workspace": "KOBE_DEV=1 bun ./src/tui-react/workspace/mock-host.tsx",
|
|
45
|
+
"dev:sandbox": "bun run scripts/dev-sandbox.ts run",
|
|
46
|
+
"dev:sandbox:reset": "bun run scripts/dev-sandbox.ts reset",
|
|
47
|
+
"build": "bun run scripts/build.ts",
|
|
48
|
+
"build:with-web": "bun run build",
|
|
49
|
+
"compile": "bun run scripts/compile.ts",
|
|
50
|
+
"typecheck": "tsc --noEmit",
|
|
51
|
+
"check-i18n": "bun run scripts/check-i18n.ts",
|
|
52
|
+
"test": "bun run test:fast && bun run test:socket",
|
|
53
|
+
"perf:golden": "bun scripts/perf-golden.ts",
|
|
54
|
+
"pty:soak": "bun scripts/pty-soak.ts",
|
|
55
|
+
"test:fast": "vitest run --passWithNoTests --minWorkers=1 --maxWorkers=8",
|
|
56
|
+
"test:socket": "KOBE_INCLUDE_SOCKET=1 vitest run test/daemon --pool forks --minWorkers=1 --maxWorkers=4 --passWithNoTests",
|
|
57
|
+
"test:socket:coverage": "KOBE_INCLUDE_SOCKET=1 KOBE_COVERAGE_DAEMON=1 vitest run test/daemon --coverage --pool forks --minWorkers=1 --maxWorkers=4 --passWithNoTests",
|
|
58
|
+
"test:behavior": "KOBE_INCLUDE_BEHAVIOR=1 vitest run test/behavior --pool forks --minWorkers=1 --maxWorkers=1 --retry=2 --passWithNoTests",
|
|
59
|
+
"test:render": "bun test test/render --coverage --coverage-reporter=text --coverage-reporter=lcov --coverage-dir=coverage-render",
|
|
60
|
+
"coverage": "vitest run --coverage --passWithNoTests",
|
|
61
|
+
"bench": "vitest bench --run",
|
|
62
|
+
"lint": "biome check .",
|
|
63
|
+
"knip": "knip-bun",
|
|
64
|
+
"postinstall": "bun run scripts/check-preview-deps.ts || true",
|
|
65
|
+
"prepublishOnly": "bun run typecheck && bun run build"
|
|
66
|
+
},
|
|
67
|
+
"//": "biome.json + bunfig.toml live at the monorepo root since they apply repo-wide; bun.lock also lives at root (workspace-shared).",
|
|
68
|
+
"dependencies": {
|
|
69
|
+
"@ansi-tools/parser": "^1.0.15",
|
|
70
|
+
"@opentui/core": "0.4.3",
|
|
71
|
+
"@opentui/react": "0.4.3",
|
|
72
|
+
"@xterm/addon-serialize": "^0.14.0",
|
|
73
|
+
"@xterm/addon-unicode11": "^0.9.0",
|
|
74
|
+
"@xterm/headless": "^6.0.0",
|
|
75
|
+
"node-pty": "^1.1.0",
|
|
76
|
+
"react": "^19.2.8",
|
|
77
|
+
"ws": "^8.18.0"
|
|
78
|
+
},
|
|
79
|
+
"devDependencies": {
|
|
80
|
+
"@biomejs/biome": "1.9.4",
|
|
81
|
+
"@sma1lboy/kobe-daemon": "0.7.12",
|
|
82
|
+
"@tsconfig/bun": "1.0.10",
|
|
83
|
+
"@types/bun": "1.3.14",
|
|
84
|
+
"@types/node": "25.6.2",
|
|
85
|
+
"@types/react": "^19.2.0",
|
|
86
|
+
"@vitest/coverage-v8": "2.1.9",
|
|
87
|
+
"knip": "^6.14.2",
|
|
88
|
+
"kobe-web": "workspace:*",
|
|
89
|
+
"react-devtools-core": "^7.0.1",
|
|
90
|
+
"typescript": "5.8.2",
|
|
91
|
+
"vitest": "2.1.9"
|
|
92
|
+
},
|
|
93
|
+
"trustedDependencies": []
|
|
94
|
+
}
|