@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.
- package/README.md +11 -8
- package/dist/cli/index.js +232 -29341
- package/dist/web-ui/assets/AppShell-HiyATJse.css +1 -0
- package/dist/web-ui/assets/AppShell-bXCEh8xj.js +5 -0
- package/dist/web-ui/assets/Board-BFKPAdwD.js +1 -0
- package/dist/web-ui/assets/BoardPeek-oIuozKHB.js +2 -0
- package/dist/web-ui/assets/{ChatTerminal-WIjnp057.js → ChatTerminal-BVTnkMgh.js} +3 -3
- package/dist/web-ui/assets/ChatTranscript-DCIZ-_n3.js +1 -0
- package/dist/web-ui/assets/ConfirmDialog-BXm-RBo_.js +1 -0
- package/dist/web-ui/assets/IssueIntakePanel-DGdEQbqO.js +1 -0
- package/dist/web-ui/assets/IssuePeek-CsEbIvuC.js +1 -0
- package/dist/web-ui/assets/IssuesPage-2GreBreD.js +1 -0
- package/dist/web-ui/assets/SettingsPage-xk_elIU4.js +2 -0
- package/dist/web-ui/assets/SlideOver-CtnKrIUW.js +179 -0
- package/dist/web-ui/assets/ViewToggle-DFv5F2ik.js +1 -0
- package/dist/web-ui/assets/WorkspaceTabs-CDvsI8Rr.js +3 -0
- package/dist/web-ui/assets/WorkspaceTabs-DLzL-Tad.css +1 -0
- package/dist/web-ui/assets/board-Cm72w4Rb.js +1 -0
- package/dist/web-ui/assets/createLucideIcon-BoVlWivB.js +1 -0
- package/dist/web-ui/assets/external-link-C51-09OW.js +1 -0
- package/dist/web-ui/assets/index-kpGUWsyu.js +10 -0
- package/dist/web-ui/assets/index-oHeKzXZP.css +1 -0
- package/dist/web-ui/assets/issues-D5iUv4jG.js +1 -0
- package/dist/web-ui/assets/issues-qDOB1RY_.js +3 -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-DDhzNFSU.js +6 -0
- package/dist/web-ui/assets/rotate-cw-pZvvI6Ao.js +1 -0
- package/dist/web-ui/assets/routes-rLcaWakr.js +1 -0
- package/dist/web-ui/assets/settings-B_VT_pPR.js +1 -0
- package/dist/web-ui/assets/store-C4ms6xvg.js +2 -0
- package/dist/web-ui/assets/{tabs-CmaDBSUQ.js → tabs-Db1r1FTh.js} +1 -1
- package/dist/web-ui/assets/task._taskId-Bp5WJKpu.js +1 -0
- package/dist/web-ui/assets/terminal-Ckm9XXat.js +1 -0
- package/dist/web-ui/assets/terminal-DV9S9xMa.js +1 -0
- package/dist/web-ui/assets/text-match-C3BRtNXy.js +1 -0
- package/dist/web-ui/assets/toast-CfAxitbT.js +1 -0
- package/dist/web-ui/assets/web-transport-BS7imUMS.js +1 -0
- package/dist/web-ui/index.html +8 -5
- package/dist/web-ui/origin-policy.mjs +41 -0
- package/dist/web-ui/pty-server.mjs +38 -181
- package/dist/web-ui/pty-session-lifecycle.mjs +177 -0
- package/package.json +5 -3
- package/dist/web-ui/assets/AppShell-CT-Wueej.js +0 -8
- package/dist/web-ui/assets/AppShell-NZ5KYJKh.css +0 -1
- package/dist/web-ui/assets/IssuePeek-DS8gwn8L.js +0 -185
- package/dist/web-ui/assets/ViewToggle-BM9Leqct.js +0 -1
- package/dist/web-ui/assets/board-BicE8Mfe.js +0 -1
- package/dist/web-ui/assets/index-B9eBRSoV.css +0 -2
- package/dist/web-ui/assets/index-BsVnxIKo.js +0 -10
- package/dist/web-ui/assets/issues-Djlu09em.js +0 -1
- package/dist/web-ui/assets/routes-BmyEyMrf.js +0 -1
- package/dist/web-ui/assets/task._taskId-CmRCr8jG.js +0 -1
- package/dist/web-ui/assets/terminal-BUdn99hL.js +0 -1
- package/dist/web-ui/assets/terminal-CapzGZr6.js +0 -1
- package/dist/web-ui/assets/useNavigate-CXvSccc8.js +0 -1
- package/dist/web-ui/assets/vendor-Bfrh7rZ5.js +0 -1
- package/dist/web-ui/fonts/JetBrainsMonoNerdFontMono-Regular.ttf +0 -0
- /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
|
|
7
|
-
*
|
|
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
|
|
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
|
-
|
|
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:${
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
|
166
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
|
|
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 (!
|
|
119
|
+
if (!result.sent) {
|
|
180
120
|
respond(404, { sent: false, error: "no such tab" })
|
|
181
121
|
return
|
|
182
122
|
}
|
|
183
|
-
|
|
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
|
-
|
|
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 ?
|
|
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
|
|
252
|
-
//
|
|
253
|
-
// bind
|
|
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 }) =>
|
|
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
|
-
|
|
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} (
|
|
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
|
-
|
|
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.
|
|
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
|
},
|