@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.
Files changed (82) hide show
  1. package/README.md +60 -0
  2. package/dist/cli/index.js +959 -0
  3. package/dist/cli/kobe.js +959 -0
  4. package/dist/cli/pty-host-node.mjs +1015 -0
  5. package/dist/cli/rove.js +959 -0
  6. package/dist/pulse-n3cq1btw.wav +0 -0
  7. package/dist/skills/kobe/SKILL.md +317 -0
  8. package/dist/web-ui/assets/AppShell-BTjvuY-t.js +5 -0
  9. package/dist/web-ui/assets/AppShell-HiyATJse.css +1 -0
  10. package/dist/web-ui/assets/Board-DPB4h6mJ.js +1 -0
  11. package/dist/web-ui/assets/BoardPeek-69VJptGV.js +2 -0
  12. package/dist/web-ui/assets/ChatTerminal-BrP-ENHg.css +1 -0
  13. package/dist/web-ui/assets/ChatTerminal-MCI1aBbT.js +144 -0
  14. package/dist/web-ui/assets/ChatTranscript-DLthbnmB.js +1 -0
  15. package/dist/web-ui/assets/ConfirmDialog-CDPOtXww.js +1 -0
  16. package/dist/web-ui/assets/IssueIntakePanel-XYsIcIhp.js +1 -0
  17. package/dist/web-ui/assets/IssuePeek-CP_rAkqO.js +1 -0
  18. package/dist/web-ui/assets/IssuesPage-BR5mpuo-.js +1 -0
  19. package/dist/web-ui/assets/RichEditor-B4sEOPyN.css +1 -0
  20. package/dist/web-ui/assets/RichEditor-DJFl5TvH.js +173 -0
  21. package/dist/web-ui/assets/SettingsPage-CLRhr-sg.js +2 -0
  22. package/dist/web-ui/assets/SlideOver-Dxtk6Gn2.js +1 -0
  23. package/dist/web-ui/assets/ViewToggle-BdPCMBWa.js +1 -0
  24. package/dist/web-ui/assets/WorkspaceTabs-CMKlvN_Y.js +3 -0
  25. package/dist/web-ui/assets/WorkspaceTabs-DLzL-Tad.css +1 -0
  26. package/dist/web-ui/assets/active-task-GeQZqbJK.js +1 -0
  27. package/dist/web-ui/assets/activity-BjTd1D8q.js +1 -0
  28. package/dist/web-ui/assets/arrow-left-DNM2CHhS.js +1 -0
  29. package/dist/web-ui/assets/board-CGiK0u0M.js +1 -0
  30. package/dist/web-ui/assets/chips-Da37C4KZ.js +1 -0
  31. package/dist/web-ui/assets/columns-3-CQGAnX-4.js +1 -0
  32. package/dist/web-ui/assets/createLucideIcon-CM5xLVj1.js +1 -0
  33. package/dist/web-ui/assets/desktop-BThq9eSR.js +2 -0
  34. package/dist/web-ui/assets/engines-Cnxy1hW9.js +1 -0
  35. package/dist/web-ui/assets/external-link-BwT2u1hD.js +1 -0
  36. package/dist/web-ui/assets/external-store-DJlWpyYC.js +1 -0
  37. package/dist/web-ui/assets/harness-q9gT7Jic.js +1 -0
  38. package/dist/web-ui/assets/index-CgL6oDIY.js +10 -0
  39. package/dist/web-ui/assets/index-MuA3NLRu.css +1 -0
  40. package/dist/web-ui/assets/issues-1SCRkcB0.js +4 -0
  41. package/dist/web-ui/assets/issues-BKkua1D9.js +1 -0
  42. package/dist/web-ui/assets/jetbrains-mono-latin-400-normal-6-qcROiO.woff +0 -0
  43. package/dist/web-ui/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
  44. package/dist/web-ui/assets/jetbrains-mono-latin-500-normal-BWZEU5yA.woff2 +0 -0
  45. package/dist/web-ui/assets/jetbrains-mono-latin-500-normal-CJOVTJB7.woff +0 -0
  46. package/dist/web-ui/assets/jetbrains-mono-latin-600-normal-BfsvjouI.woff +0 -0
  47. package/dist/web-ui/assets/jetbrains-mono-latin-600-normal-C8RAYTDA.woff2 +0 -0
  48. package/dist/web-ui/assets/jetbrains-mono-latin-700-normal-BYuf6tUa.woff2 +0 -0
  49. package/dist/web-ui/assets/jetbrains-mono-latin-700-normal-D3wTyLJW.woff +0 -0
  50. package/dist/web-ui/assets/lazy-issue-panels-0Rhl2uhh.js +6 -0
  51. package/dist/web-ui/assets/plus-BbX5qXba.js +1 -0
  52. package/dist/web-ui/assets/react-dom-DCnDkJvp.js +1 -0
  53. package/dist/web-ui/assets/rotate-cw-D-UUtiD0.js +1 -0
  54. package/dist/web-ui/assets/routes-SBb-uSy8.js +1 -0
  55. package/dist/web-ui/assets/search-BxbFZoup.js +1 -0
  56. package/dist/web-ui/assets/settings-tPcmEHCP.js +1 -0
  57. package/dist/web-ui/assets/store-uT65Mask.js +2 -0
  58. package/dist/web-ui/assets/tabs-Dx-TnbvG.js +1 -0
  59. package/dist/web-ui/assets/task._taskId-BlZ0vG_1.js +2 -0
  60. package/dist/web-ui/assets/task._taskId-BmObFoVZ.js +1 -0
  61. package/dist/web-ui/assets/terminal-qBEkzc-m.js +1 -0
  62. package/dist/web-ui/assets/text-match-DW3sD-Rt.js +1 -0
  63. package/dist/web-ui/assets/time-kviT26Tm.js +1 -0
  64. package/dist/web-ui/assets/toast-BXjAT0ix.js +1 -0
  65. package/dist/web-ui/assets/triage-7QLsCY8d.js +1 -0
  66. package/dist/web-ui/assets/use-focus-trap-DNMrlNR0.js +1 -0
  67. package/dist/web-ui/assets/vendor-CdtpTJrf.js +1 -0
  68. package/dist/web-ui/assets/web-transport-D0bugxxv.js +1 -0
  69. package/dist/web-ui/assets/with-selector-B5R3h-Ow.js +1 -0
  70. package/dist/web-ui/assets/worktrees-C_XKA7jM.js +1 -0
  71. package/dist/web-ui/assets/x-7cB9X7Hi.js +1 -0
  72. package/dist/web-ui/favicon.ico +0 -0
  73. package/dist/web-ui/index.html +35 -0
  74. package/dist/web-ui/logo192.png +0 -0
  75. package/dist/web-ui/logo512.png +0 -0
  76. package/dist/web-ui/manifest.json +25 -0
  77. package/dist/web-ui/origin-policy.mjs +41 -0
  78. package/dist/web-ui/pty-scrollback.mjs +51 -0
  79. package/dist/web-ui/pty-server.mjs +212 -0
  80. package/dist/web-ui/pty-session-lifecycle.mjs +282 -0
  81. package/dist/web-ui/robots.txt +3 -0
  82. 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
+ }
@@ -0,0 +1,3 @@
1
+ # https://www.robotstxt.org/robotstxt.html
2
+ User-agent: *
3
+ Disallow:
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
+ }