@sma1lboy/kobe 0.7.32 → 0.7.34

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 (65) hide show
  1. package/README.md +11 -8
  2. package/dist/cli/index.js +232 -29341
  3. package/dist/web-ui/assets/AppShell-HiyATJse.css +1 -0
  4. package/dist/web-ui/assets/AppShell-bXCEh8xj.js +5 -0
  5. package/dist/web-ui/assets/Board-BFKPAdwD.js +1 -0
  6. package/dist/web-ui/assets/BoardPeek-oIuozKHB.js +2 -0
  7. package/dist/web-ui/assets/{ChatTerminal-WIjnp057.js → ChatTerminal-BVTnkMgh.js} +3 -3
  8. package/dist/web-ui/assets/ChatTranscript-DCIZ-_n3.js +1 -0
  9. package/dist/web-ui/assets/ConfirmDialog-BXm-RBo_.js +1 -0
  10. package/dist/web-ui/assets/IssueIntakePanel-DGdEQbqO.js +1 -0
  11. package/dist/web-ui/assets/IssuePeek-CsEbIvuC.js +1 -0
  12. package/dist/web-ui/assets/IssuesPage-2GreBreD.js +1 -0
  13. package/dist/web-ui/assets/SettingsPage-xk_elIU4.js +2 -0
  14. package/dist/web-ui/assets/SlideOver-CtnKrIUW.js +179 -0
  15. package/dist/web-ui/assets/ViewToggle-DFv5F2ik.js +1 -0
  16. package/dist/web-ui/assets/WorkspaceTabs-CDvsI8Rr.js +3 -0
  17. package/dist/web-ui/assets/WorkspaceTabs-DLzL-Tad.css +1 -0
  18. package/dist/web-ui/assets/board-Cm72w4Rb.js +1 -0
  19. package/dist/web-ui/assets/createLucideIcon-BoVlWivB.js +1 -0
  20. package/dist/web-ui/assets/external-link-C51-09OW.js +1 -0
  21. package/dist/web-ui/assets/index-kpGUWsyu.js +10 -0
  22. package/dist/web-ui/assets/index-oHeKzXZP.css +1 -0
  23. package/dist/web-ui/assets/issues-D5iUv4jG.js +1 -0
  24. package/dist/web-ui/assets/issues-qDOB1RY_.js +3 -0
  25. package/dist/web-ui/assets/jetbrains-mono-latin-400-normal-6-qcROiO.woff +0 -0
  26. package/dist/web-ui/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
  27. package/dist/web-ui/assets/jetbrains-mono-latin-500-normal-BWZEU5yA.woff2 +0 -0
  28. package/dist/web-ui/assets/jetbrains-mono-latin-500-normal-CJOVTJB7.woff +0 -0
  29. package/dist/web-ui/assets/jetbrains-mono-latin-600-normal-BfsvjouI.woff +0 -0
  30. package/dist/web-ui/assets/jetbrains-mono-latin-600-normal-C8RAYTDA.woff2 +0 -0
  31. package/dist/web-ui/assets/jetbrains-mono-latin-700-normal-BYuf6tUa.woff2 +0 -0
  32. package/dist/web-ui/assets/jetbrains-mono-latin-700-normal-D3wTyLJW.woff +0 -0
  33. package/dist/web-ui/assets/lazy-issue-panels-DDhzNFSU.js +6 -0
  34. package/dist/web-ui/assets/rotate-cw-pZvvI6Ao.js +1 -0
  35. package/dist/web-ui/assets/routes-rLcaWakr.js +1 -0
  36. package/dist/web-ui/assets/settings-B_VT_pPR.js +1 -0
  37. package/dist/web-ui/assets/store-C4ms6xvg.js +2 -0
  38. package/dist/web-ui/assets/{tabs-CmaDBSUQ.js → tabs-Db1r1FTh.js} +1 -1
  39. package/dist/web-ui/assets/task._taskId-Bp5WJKpu.js +1 -0
  40. package/dist/web-ui/assets/terminal-Ckm9XXat.js +1 -0
  41. package/dist/web-ui/assets/terminal-DV9S9xMa.js +1 -0
  42. package/dist/web-ui/assets/text-match-C3BRtNXy.js +1 -0
  43. package/dist/web-ui/assets/toast-CfAxitbT.js +1 -0
  44. package/dist/web-ui/assets/web-transport-BS7imUMS.js +1 -0
  45. package/dist/web-ui/index.html +8 -5
  46. package/dist/web-ui/origin-policy.mjs +41 -0
  47. package/dist/web-ui/pty-server.mjs +38 -181
  48. package/dist/web-ui/pty-session-lifecycle.mjs +177 -0
  49. package/package.json +5 -3
  50. package/dist/web-ui/assets/AppShell-CT-Wueej.js +0 -8
  51. package/dist/web-ui/assets/AppShell-NZ5KYJKh.css +0 -1
  52. package/dist/web-ui/assets/IssuePeek-DS8gwn8L.js +0 -185
  53. package/dist/web-ui/assets/ViewToggle-BM9Leqct.js +0 -1
  54. package/dist/web-ui/assets/board-BicE8Mfe.js +0 -1
  55. package/dist/web-ui/assets/index-B9eBRSoV.css +0 -2
  56. package/dist/web-ui/assets/index-BsVnxIKo.js +0 -10
  57. package/dist/web-ui/assets/issues-Djlu09em.js +0 -1
  58. package/dist/web-ui/assets/routes-BmyEyMrf.js +0 -1
  59. package/dist/web-ui/assets/task._taskId-CmRCr8jG.js +0 -1
  60. package/dist/web-ui/assets/terminal-BUdn99hL.js +0 -1
  61. package/dist/web-ui/assets/terminal-CapzGZr6.js +0 -1
  62. package/dist/web-ui/assets/useNavigate-CXvSccc8.js +0 -1
  63. package/dist/web-ui/assets/vendor-Bfrh7rZ5.js +0 -1
  64. package/dist/web-ui/fonts/JetBrainsMonoNerdFontMono-Regular.ttf +0 -0
  65. /package/dist/web-ui/assets/{IssuePeek-B4sEOPyN.css → SlideOver-B4sEOPyN.css} +0 -0
@@ -3,8 +3,8 @@
3
3
  * bun, so the live terminals run here as a separate node process).
4
4
  *
5
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 the bun
7
- * bridge by taskId + mode) and kept alive across WebSocket reconnects, so a
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
8
  * page refresh re-attaches to the same process. Closing a tab (POST
9
9
  * /pty/close) kills its PTY.
10
10
  *
@@ -16,16 +16,17 @@
16
16
  import { createServer } from "node:http"
17
17
  import { spawn } from "node-pty"
18
18
  import { WebSocketServer } from "ws"
19
+ import { allowedHostForBindHost, originAllowed } from "./origin-policy.mjs"
19
20
  import { createScrollback } from "./pty-scrollback.mjs"
21
+ import { createPtySessionManager } from "./pty-session-lifecycle.mjs"
20
22
 
21
23
  const PORT = Number.parseInt(process.env.KOBE_PTY_PORT ?? "5175", 10)
22
- const BRIDGE_PORT = Number.parseInt(process.env.KOBE_BRIDGE_PORT ?? "5174", 10)
24
+ const DAEMON_WEB_PORT = Number.parseInt(process.env.KOBE_DAEMON_WEB_PORT ?? "5174", 10)
23
25
  const SCROLLBACK_CAP = 256 * 1024 // bytes of recent output replayed on (re)attach
24
26
  const HEALTH_PATH = "/__kobe_web"
25
27
  const HEALTH_MARKER = "kobe-web"
26
-
27
- /** tabId { pty, buffer, sockets } */
28
- const tabs = new Map()
28
+ const HOST = process.env.KOBE_WEB_HOST?.trim() || "127.0.0.1"
29
+ const ALLOWED_HOST = allowedHostForBindHost(HOST)
29
30
 
30
31
  function ptyEnv() {
31
32
  const { NO_COLOR: _noColor, ...env } = process.env
@@ -39,78 +40,19 @@ function ptyEnv() {
39
40
 
40
41
  async function fetchSpec(taskId, mode) {
41
42
  const path = mode === "shell" ? "/api/terminal-spec" : "/api/engine-spec"
42
- const res = await fetch(`http://localhost:${BRIDGE_PORT}${path}?taskId=${encodeURIComponent(taskId)}`)
43
+ const res = await fetch(`http://localhost:${DAEMON_WEB_PORT}${path}?taskId=${encodeURIComponent(taskId)}`)
43
44
  const json = await res.json()
44
45
  if (!res.ok || json.error) throw new Error(json.error ?? `engine-spec failed (${res.status})`)
45
46
  return json // { cwd, command: string[] }
46
47
  }
47
48
 
48
- function spawnTab(tabId, spec, cols, rows) {
49
- const [cmd, ...args] = spec.command
50
- const pty = spawn(cmd, args, {
51
- name: "xterm-256color",
52
- cols,
53
- rows,
54
- cwd: spec.cwd,
55
- env: ptyEnv(),
56
- })
57
- // Scrollback is a bounded ring of chunks, not a single growing string: the
58
- // old `(buffer + data).slice(-CAP)` re-flattened ~256KB on EVERY chunk once
59
- // the cap was reached (quadratic during heavy streaming). `push` is O(chunk);
60
- // the string is materialized only on (re)attach replay.
61
- const entry = { pty, scrollback: createScrollback(SCROLLBACK_CAP), sockets: new Set() }
62
- pty.onData((data) => {
63
- entry.scrollback.push(data)
64
- for (const ws of entry.sockets) if (ws.readyState === ws.OPEN) ws.send(data)
65
- })
66
- pty.onExit(() => {
67
- for (const ws of entry.sockets) if (ws.readyState === ws.OPEN) ws.close(1000, "engine exited")
68
- tabs.delete(tabId)
69
- })
70
- tabs.set(tabId, entry)
71
- return entry
72
- }
73
-
74
- function closeTab(tabId) {
75
- const entry = tabs.get(tabId)
76
- if (!entry) return false
77
- try {
78
- entry.pty.kill()
79
- } catch {
80
- /* already gone */
81
- }
82
- tabs.delete(tabId)
83
- return true
84
- }
85
-
86
- // In-flight spawns, keyed by tabId. `fetchSpec` is async, so a naive
87
- // check-then-`spawnTab` lets two concurrent attaches for the SAME tab (React
88
- // StrictMode double-mount, a fast reconnect racing the first attach, or a
89
- // board /pty/send racing a WS open) both pass the `!entry` check and both
90
- // spawn — the second `tabs.set` orphans the first PTY (no longer in `tabs`, so
91
- // /pty/close and shutdown can never kill it, and its onExit deletes the live
92
- // entry's mapping). Coalescing onto one promise per tabId makes spawn
93
- // single-flight, so concurrent attaches share exactly one PTY.
94
- const pendingSpawns = new Map()
95
-
96
- async function ensureTab(tabId, taskId, mode, cols, rows) {
97
- const existing = tabs.get(tabId)
98
- if (existing) return existing
99
- const inflight = pendingSpawns.get(tabId)
100
- if (inflight) return inflight
101
- const p = (async () => {
102
- const spec = await fetchSpec(taskId, mode)
103
- // A prior spawn may have completed (and not yet been closed) while we
104
- // awaited the spec — reuse it rather than spawning a duplicate.
105
- return tabs.get(tabId) ?? spawnTab(tabId, spec, cols, rows)
106
- })()
107
- pendingSpawns.set(tabId, p)
108
- try {
109
- return await p
110
- } finally {
111
- pendingSpawns.delete(tabId)
112
- }
113
- }
49
+ const ptySessions = createPtySessionManager({
50
+ fetchSpec,
51
+ spawnPty: spawn,
52
+ createScrollback,
53
+ scrollbackCap: SCROLLBACK_CAP,
54
+ env: ptyEnv,
55
+ })
114
56
 
115
57
  const server = createServer((req, res) => {
116
58
  const url = new URL(req.url ?? "/", "http://localhost")
@@ -132,8 +74,7 @@ const server = createServer((req, res) => {
132
74
  // Sending text DRIVES the engine like a keyboard, so unlike /pty/close
133
75
  // (best-effort kill) this holds the same origin policy as the WS attach:
134
76
  // localhost pages or non-browser clients only.
135
- const origin = req.headers.origin
136
- if (origin && !LOCAL_ORIGIN.test(origin)) {
77
+ if (!originAllowed(req.headers.origin, { allowedHost: ALLOWED_HOST })) {
137
78
  res.writeHead(403)
138
79
  res.end()
139
80
  return
@@ -162,53 +103,24 @@ const server = createServer((req, res) => {
162
103
  respond(400, { sent: false, error: "tab and text are required" })
163
104
  return
164
105
  }
165
- let entry = tabs.get(tab)
166
- let spawned = false
167
- if (!entry && typeof taskId === "string" && taskId) {
106
+ let result
107
+ try {
168
108
  // Spawn-on-send: a board action can fire without the terminal ever
169
109
  // opening — output lands in the scrollback ring for the next attach.
170
- // ensureTab coalesces with any concurrent WS attach for the same tab.
171
- try {
172
- entry = await ensureTab(tab, taskId, "engine", 80, 24)
173
- spawned = true
174
- } catch (err) {
175
- respond(500, { sent: false, error: `failed to start engine: ${err?.message ?? err}` })
176
- return
177
- }
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
178
118
  }
179
- if (!entry) {
119
+ if (!result.sent) {
180
120
  respond(404, { sent: false, error: "no such tab" })
181
121
  return
182
122
  }
183
- // Same submit contract as the composer / tmux pasteAndSubmit:
184
- // bracketed paste so a multi-line prompt arrives as ONE paste, then
185
- // Enter. A freshly spawned engine gets a grace delay so the paste
186
- // isn't eaten by its startup. The Enter MUST land in a separate,
187
- // later write: written back-to-back it coalesces into the same tty
188
- // chunk as the paste and claude treats it as paste content — the
189
- // text sits in the composer and never submits. (tmux pasteAndSubmit
190
- // gets this separation for free from its two commands; the web
191
- // composer from its two WS messages.)
192
- const target = entry
193
- setTimeout(
194
- () => {
195
- try {
196
- target.pty.write(`\x1b[200~${text}\x1b[201~`)
197
- } catch {
198
- /* engine died between spawn and paste — next attach shows why */
199
- return
200
- }
201
- setTimeout(() => {
202
- try {
203
- target.pty.write("\r")
204
- } catch {
205
- /* same: best-effort */
206
- }
207
- }, 150)
208
- },
209
- spawned ? 2500 : 0,
210
- )
211
- respond(200, { sent: true, spawned })
123
+ respond(200, { sent: true, spawned: result.spawned })
212
124
  })
213
125
  return
214
126
  }
@@ -217,8 +129,7 @@ const server = createServer((req, res) => {
217
129
  // DoS the session (tab ids are client-generated/observable), so hold the
218
130
  // same origin policy as /pty/send and the WS attach: localhost pages or
219
131
  // non-browser clients (no Origin) only.
220
- const origin = req.headers.origin
221
- if (origin && !LOCAL_ORIGIN.test(origin)) {
132
+ if (!originAllowed(req.headers.origin, { allowedHost: ALLOWED_HOST })) {
222
133
  res.writeHead(403)
223
134
  res.end()
224
135
  return
@@ -234,7 +145,7 @@ const server = createServer((req, res) => {
234
145
  } catch {
235
146
  /* ignore */
236
147
  }
237
- const ok = tab ? closeTab(tab) : false
148
+ const ok = tab ? ptySessions.closeSession(tab) : false
238
149
  res.writeHead(200, {
239
150
  "content-type": "application/json",
240
151
  "access-control-allow-origin": "*",
@@ -248,15 +159,14 @@ const server = createServer((req, res) => {
248
159
  })
249
160
 
250
161
  // A PTY WS is arbitrary command exec in the worktree, so reject cross-origin
251
- // upgrades: a browser sends an Origin header, and only localhost pages may
252
- // attach (defends a malicious local page / DNS-rebinding even on the loopback
253
- // bind). Non-browser clients (no Origin) are allowed — there's no browser to
254
- // forge their request.
255
- const LOCAL_ORIGIN = /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/
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.
256
166
  const wss = new WebSocketServer({
257
167
  server,
258
168
  path: "/pty",
259
- verifyClient: ({ origin }) => !origin || LOCAL_ORIGIN.test(origin),
169
+ verifyClient: ({ origin }) => originAllowed(origin, { allowedHost: ALLOWED_HOST }),
260
170
  })
261
171
 
262
172
  wss.on("connection", (ws, req) => {
@@ -273,10 +183,9 @@ wss.on("connection", (ws, req) => {
273
183
  }
274
184
 
275
185
  void (async () => {
276
- let entry
277
186
  try {
278
187
  // Single-flight spawn: concurrent attaches for this tab share one PTY.
279
- entry = await ensureTab(tabId, taskId, mode, cols, rows)
188
+ await ptySessions.attachSocket({ ws, tabId, taskId, mode, cols, rows })
280
189
  } catch (err) {
281
190
  if (ws.readyState === ws.OPEN) {
282
191
  ws.send(`\r\nfailed to start ${mode}: ${err?.message ?? err}\r\n`)
@@ -284,69 +193,17 @@ wss.on("connection", (ws, req) => {
284
193
  }
285
194
  return
286
195
  }
287
-
288
- // Snapshot the scrollback BEFORE joining the live fan-out. If a chunk
289
- // arrives between `sockets.add` and the replay it would be sent live (the
290
- // socket is already in the set) AND included in the replay — the browser
291
- // writes the same bytes twice, briefly garbling the screen on (re)attach.
292
- // Capturing the replay first, then adding the socket, makes attach
293
- // exactly-once: the snapshot is everything-so-far, and anything after it
294
- // arrives only via the live path.
295
- const replay = entry.scrollback.length() > 0 ? entry.scrollback.replay() : ""
296
- entry.sockets.add(ws)
297
- if (replay && ws.readyState === ws.OPEN) ws.send(replay)
298
- // The PTY can exit between spawn/attach and any op below; node-pty throws
299
- // on resize/write against a dead handle, and this sidecar has no
300
- // uncaughtException net (unlike the daemon) — an unguarded throw in a `ws`
301
- // callback kills the whole pty-server process. Guard every pty op.
302
- const safePty = (fn) => {
303
- if (!tabs.has(tabId)) return
304
- try {
305
- fn(entry.pty)
306
- } catch {
307
- /* engine died — its onExit already closed the sockets */
308
- }
309
- }
310
- safePty((pty) => pty.resize(cols, rows))
311
-
312
- ws.on("message", (raw) => {
313
- const text = raw.toString()
314
- if (text.startsWith("{")) {
315
- try {
316
- const msg = JSON.parse(text)
317
- if (msg && msg.type === "resize" && Number.isFinite(msg.cols) && Number.isFinite(msg.rows)) {
318
- safePty((pty) => pty.resize(Math.max(1, msg.cols | 0), Math.max(1, msg.rows | 0)))
319
- return
320
- }
321
- } catch {
322
- /* fall through to raw write */
323
- }
324
- }
325
- safePty((pty) => pty.write(text))
326
- })
327
-
328
- ws.on("close", () => {
329
- entry.sockets.delete(ws)
330
- // Keep the PTY alive for reconnects; it's killed only via /pty/close.
331
- })
332
196
  })()
333
197
  })
334
198
 
335
199
  // Bind loopback by default — a PTY is an arbitrary shell/engine in the
336
200
  // worktree, so it must never listen on all interfaces. KOBE_WEB_HOST overrides.
337
- const HOST = process.env.KOBE_WEB_HOST?.trim() || "127.0.0.1"
338
201
  server.listen(PORT, HOST, () => {
339
- process.stdout.write(`kobe pty-server listening on ${HOST}:${PORT} (bridge :${BRIDGE_PORT})\n`)
202
+ process.stdout.write(`kobe pty-server listening on ${HOST}:${PORT} (daemon-web :${DAEMON_WEB_PORT})\n`)
340
203
  })
341
204
 
342
205
  const shutdown = () => {
343
- for (const [, entry] of tabs) {
344
- try {
345
- entry.pty.kill()
346
- } catch {
347
- /* ignore */
348
- }
349
- }
206
+ ptySessions.shutdown()
350
207
  wss.close()
351
208
  server.close()
352
209
  process.exit(0)
@@ -0,0 +1,177 @@
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
+ export function createPtySessionManager({
16
+ fetchSpec,
17
+ spawnPty,
18
+ createScrollback,
19
+ scrollbackCap,
20
+ env,
21
+ setTimeoutFn = setTimeout,
22
+ submitDelays = DEFAULT_SUBMIT_DELAYS,
23
+ }) {
24
+ /** @type {Map<string, { pty: any, scrollback: ReturnType<createScrollback>, sockets: Set<any> }>} */
25
+ const sessions = new Map()
26
+ /** @type {Map<string, Promise<any>>} */
27
+ const pendingSpawns = new Map()
28
+
29
+ function spawnSession(tabId, spec, cols, rows) {
30
+ const [cmd, ...args] = spec.command
31
+ const spawnEnv = typeof env === "function" ? env() : env
32
+ const pty = spawnPty(cmd, args, {
33
+ name: "xterm-256color",
34
+ cols,
35
+ rows,
36
+ cwd: spec.cwd,
37
+ env: spawnEnv,
38
+ })
39
+ const entry = {
40
+ pty,
41
+ scrollback: createScrollback(scrollbackCap),
42
+ sockets: new Set(),
43
+ }
44
+ pty.onData((data) => {
45
+ entry.scrollback.push(data)
46
+ for (const ws of entry.sockets) {
47
+ if (ws.readyState === ws.OPEN) ws.send(data)
48
+ }
49
+ })
50
+ pty.onExit(() => {
51
+ for (const ws of entry.sockets) {
52
+ if (ws.readyState === ws.OPEN) ws.close(1000, "engine exited")
53
+ }
54
+ if (sessions.get(tabId) === entry) sessions.delete(tabId)
55
+ })
56
+ sessions.set(tabId, entry)
57
+ return entry
58
+ }
59
+
60
+ async function ensureSession(tabId, taskId, mode, cols, rows) {
61
+ const existing = sessions.get(tabId)
62
+ if (existing) return existing
63
+ const inflight = pendingSpawns.get(tabId)
64
+ if (inflight) return inflight
65
+ const p = (async () => {
66
+ const spec = await fetchSpec(taskId, mode)
67
+ return sessions.get(tabId) ?? spawnSession(tabId, spec, cols, rows)
68
+ })()
69
+ pendingSpawns.set(tabId, p)
70
+ try {
71
+ return await p
72
+ } finally {
73
+ pendingSpawns.delete(tabId)
74
+ }
75
+ }
76
+
77
+ function safePty(tabId, entry, fn) {
78
+ if (sessions.get(tabId) !== entry) return
79
+ try {
80
+ fn(entry.pty)
81
+ } catch {
82
+ /* engine died — its onExit closes sockets and clears the entry */
83
+ }
84
+ }
85
+
86
+ async function attachSocket({ ws, tabId, taskId, mode, cols, rows }) {
87
+ const entry = await ensureSession(tabId, taskId, mode, cols, rows)
88
+ const replay = entry.scrollback.length() > 0 ? entry.scrollback.replay() : ""
89
+ entry.sockets.add(ws)
90
+ if (replay && ws.readyState === ws.OPEN) ws.send(replay)
91
+ safePty(tabId, entry, (pty) => pty.resize(cols, rows))
92
+
93
+ ws.on("message", (raw) => {
94
+ const text = raw.toString()
95
+ if (text.startsWith("{")) {
96
+ try {
97
+ const msg = JSON.parse(text)
98
+ if (msg && msg.type === "resize" && Number.isFinite(msg.cols) && Number.isFinite(msg.rows)) {
99
+ safePty(tabId, entry, (pty) => pty.resize(Math.max(1, msg.cols | 0), Math.max(1, msg.rows | 0)))
100
+ return
101
+ }
102
+ } catch {
103
+ /* fall through to raw write */
104
+ }
105
+ }
106
+ safePty(tabId, entry, (pty) => pty.write(text))
107
+ })
108
+
109
+ ws.on("close", () => {
110
+ entry.sockets.delete(ws)
111
+ })
112
+
113
+ return entry
114
+ }
115
+
116
+ function closeSession(tabId) {
117
+ const entry = sessions.get(tabId)
118
+ if (!entry) return false
119
+ try {
120
+ entry.pty.kill()
121
+ } catch {
122
+ /* already gone */
123
+ }
124
+ if (sessions.get(tabId) === entry) sessions.delete(tabId)
125
+ return true
126
+ }
127
+
128
+ async function sendText({ tabId, taskId, text }) {
129
+ let entry = sessions.get(tabId)
130
+ let spawned = false
131
+ if (!entry && taskId) {
132
+ entry = await ensureSession(tabId, taskId, "engine", 80, 24)
133
+ spawned = true
134
+ }
135
+ if (!entry) return { sent: false, spawned: false, missing: true }
136
+
137
+ const target = entry
138
+ const pasteDelay = spawned ? submitDelays.spawnedPasteMs : submitDelays.existingPasteMs
139
+ setTimeoutFn(() => {
140
+ try {
141
+ target.pty.write(`\x1b[200~${text}\x1b[201~`)
142
+ } catch {
143
+ return
144
+ }
145
+ setTimeoutFn(() => {
146
+ try {
147
+ target.pty.write("\r")
148
+ } catch {
149
+ /* best-effort */
150
+ }
151
+ }, submitDelays.enterMs)
152
+ }, pasteDelay)
153
+ return { sent: true, spawned }
154
+ }
155
+
156
+ function shutdown() {
157
+ for (const entry of sessions.values()) {
158
+ try {
159
+ entry.pty.kill()
160
+ } catch {
161
+ /* ignore */
162
+ }
163
+ }
164
+ sessions.clear()
165
+ pendingSpawns.clear()
166
+ }
167
+
168
+ return {
169
+ attachSocket,
170
+ closeSession,
171
+ ensureSession,
172
+ sendText,
173
+ shutdown,
174
+ sessionCount: () => sessions.size,
175
+ pendingSpawnCount: () => pendingSpawns.size,
176
+ }
177
+ }
package/package.json CHANGED
@@ -1,14 +1,14 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "@sma1lboy/kobe",
4
- "version": "0.7.32",
4
+ "version": "0.7.34",
5
5
  "description": "TUI orchestrator for Claude Code (codename)",
6
6
  "type": "module",
7
7
  "packageManager": "bun@1.3.13",
8
8
  "bin": {
9
9
  "kobe": "dist/cli/index.js"
10
10
  },
11
- "files": ["dist", "README.md", "LICENSE"],
11
+ "files": ["dist/cli", "dist/web-ui", "dist/*.wav", "README.md", "LICENSE"],
12
12
  "publishConfig": {
13
13
  "access": "public"
14
14
  },
@@ -27,7 +27,9 @@
27
27
  "dev": "env KOBE_DEV=1 bun --preload @opentui/solid/preload --conditions=browser ./src/cli/index.ts",
28
28
  "dev:sandbox": "mkdir -p .dev-sandbox/home && env KOBE_DEV=1 KOBE_HOME_DIR=$PWD/.dev-sandbox/home KOBE_TMUX_SOCKET=kobe-sandbox bun --preload @opentui/solid/preload --conditions=browser ./src/cli/index.ts",
29
29
  "dev:sandbox:reset": "env KOBE_TMUX_SOCKET=kobe-sandbox bun ./src/cli/index.ts kill-sessions",
30
+ "dev:version": "bash ../../scripts/dev-version.sh",
30
31
  "build": "bun run scripts/build.ts",
32
+ "build:with-web": "bun run build",
31
33
  "compile": "bun run scripts/compile.ts",
32
34
  "typecheck": "tsc --noEmit",
33
35
  "test": "bun run test:fast && bun run test:socket",
@@ -45,7 +47,6 @@
45
47
  "@ansi-tools/parser": "^1.0.15",
46
48
  "@opentui/core": "0.2.4",
47
49
  "@opentui/solid": "0.2.4",
48
- "node-pty": "^1.1.0",
49
50
  "ws": "^8.18.0",
50
51
  "solid-js": "1.9.12"
51
52
  },
@@ -57,6 +58,7 @@
57
58
  "@types/node": "25.6.2",
58
59
  "knip": "^6.14.2",
59
60
  "kobe-web": "workspace:*",
61
+ "node-pty": "^1.1.0",
60
62
  "typescript": "5.8.2",
61
63
  "vitest": "2.1.9"
62
64
  },