kaizenai 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/README.md +246 -0
- package/bin/kaizen +15 -0
- package/dist/client/apple-touch-icon.png +0 -0
- package/dist/client/assets/index-D-ORCGrq.js +603 -0
- package/dist/client/assets/index-r28mcHqz.css +32 -0
- package/dist/client/favicon.png +0 -0
- package/dist/client/fonts/body-medium.woff2 +0 -0
- package/dist/client/fonts/body-regular-italic.woff2 +0 -0
- package/dist/client/fonts/body-regular.woff2 +0 -0
- package/dist/client/fonts/body-semibold.woff2 +0 -0
- package/dist/client/index.html +22 -0
- package/dist/client/manifest-dark.webmanifest +24 -0
- package/dist/client/manifest.webmanifest +24 -0
- package/dist/client/pwa-192.png +0 -0
- package/dist/client/pwa-512.png +0 -0
- package/dist/client/pwa-icon.svg +15 -0
- package/dist/client/pwa-splash.png +0 -0
- package/dist/client/pwa-splash.svg +15 -0
- package/package.json +103 -0
- package/src/server/acp-shared.ts +315 -0
- package/src/server/agent.ts +1120 -0
- package/src/server/attachments.ts +133 -0
- package/src/server/backgrounds.ts +74 -0
- package/src/server/cli-runtime.ts +333 -0
- package/src/server/cli-supervisor.ts +81 -0
- package/src/server/cli.ts +68 -0
- package/src/server/codex-app-server-protocol.ts +453 -0
- package/src/server/codex-app-server.ts +1350 -0
- package/src/server/cursor-acp.ts +819 -0
- package/src/server/discovery.ts +322 -0
- package/src/server/event-store.ts +1369 -0
- package/src/server/events.ts +244 -0
- package/src/server/external-open.ts +272 -0
- package/src/server/gemini-acp.ts +844 -0
- package/src/server/gemini-cli.ts +525 -0
- package/src/server/generate-title.ts +36 -0
- package/src/server/git-manager.ts +79 -0
- package/src/server/git-repository.ts +101 -0
- package/src/server/harness-types.ts +20 -0
- package/src/server/keybindings.ts +177 -0
- package/src/server/machine-name.ts +22 -0
- package/src/server/paths.ts +112 -0
- package/src/server/process-utils.ts +22 -0
- package/src/server/project-icon.ts +344 -0
- package/src/server/project-metadata.ts +10 -0
- package/src/server/provider-catalog.ts +85 -0
- package/src/server/provider-settings.ts +155 -0
- package/src/server/quick-response.ts +153 -0
- package/src/server/read-models.ts +275 -0
- package/src/server/recovery.ts +507 -0
- package/src/server/restart.ts +30 -0
- package/src/server/server.ts +244 -0
- package/src/server/terminal-manager.ts +350 -0
- package/src/server/theme-settings.ts +179 -0
- package/src/server/update-manager.ts +230 -0
- package/src/server/usage/base-provider-usage.ts +57 -0
- package/src/server/usage/claude-usage.ts +558 -0
- package/src/server/usage/codex-usage.ts +144 -0
- package/src/server/usage/cursor-browser.ts +120 -0
- package/src/server/usage/cursor-cookies.ts +390 -0
- package/src/server/usage/cursor-usage.ts +490 -0
- package/src/server/usage/gemini-usage.ts +24 -0
- package/src/server/usage/provider-usage.ts +61 -0
- package/src/server/usage/test-helpers.ts +9 -0
- package/src/server/usage/types.ts +54 -0
- package/src/server/usage/utils.ts +325 -0
- package/src/server/ws-router.ts +717 -0
- package/src/shared/branding.ts +83 -0
- package/src/shared/dev-ports.ts +43 -0
- package/src/shared/ports.ts +2 -0
- package/src/shared/protocol.ts +152 -0
- package/src/shared/tools.ts +251 -0
- package/src/shared/types.ts +1028 -0
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import path from "node:path"
|
|
2
|
+
import { APP_NAME, getRuntimeProfile } from "../shared/branding"
|
|
3
|
+
import { EventStore } from "./event-store"
|
|
4
|
+
import { AgentCoordinator } from "./agent"
|
|
5
|
+
import { ATTACHMENTS_ROUTE_PREFIX, resolveAttachmentPath } from "./attachments"
|
|
6
|
+
import { discoverProjects, type DiscoveredProject } from "./discovery"
|
|
7
|
+
import { GitManager } from "./git-manager"
|
|
8
|
+
import { KeybindingsManager } from "./keybindings"
|
|
9
|
+
import { ProviderSettingsManager } from "./provider-settings"
|
|
10
|
+
import { ThemeSettingsManager } from "./theme-settings"
|
|
11
|
+
import { getMachineDisplayName } from "./machine-name"
|
|
12
|
+
import { listProjectDirectories } from "./paths"
|
|
13
|
+
import { TerminalManager } from "./terminal-manager"
|
|
14
|
+
import { UpdateManager } from "./update-manager"
|
|
15
|
+
import type { UpdateInstallAttemptResult } from "./cli-runtime"
|
|
16
|
+
import { importProjectHistory } from "./recovery"
|
|
17
|
+
import { createWsRouter, type ClientState } from "./ws-router"
|
|
18
|
+
import { getSystemBackgrounds, resolveBackgroundPath } from "./backgrounds"
|
|
19
|
+
|
|
20
|
+
export interface StartKaizenServerOptions {
|
|
21
|
+
port?: number
|
|
22
|
+
host?: string
|
|
23
|
+
strictPort?: boolean
|
|
24
|
+
onMigrationProgress?: (message: string) => void
|
|
25
|
+
update?: {
|
|
26
|
+
version: string
|
|
27
|
+
fetchLatestVersion: (packageName: string) => Promise<string>
|
|
28
|
+
installVersion: (packageName: string, version: string) => UpdateInstallAttemptResult
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function startKaizenServer(options: StartKaizenServerOptions = {}) {
|
|
33
|
+
const port = options.port ?? 3210
|
|
34
|
+
const hostname = options.host ?? "127.0.0.1"
|
|
35
|
+
const strictPort = options.strictPort ?? false
|
|
36
|
+
const store = new EventStore()
|
|
37
|
+
const machineDisplayName = getMachineDisplayName()
|
|
38
|
+
await store.initialize()
|
|
39
|
+
await store.migrateLegacyTranscripts(options.onMigrationProgress)
|
|
40
|
+
for (const project of store.listProjects()) {
|
|
41
|
+
await importProjectHistory({
|
|
42
|
+
store,
|
|
43
|
+
projectId: project.id,
|
|
44
|
+
repoKey: project.repoKey,
|
|
45
|
+
localPath: project.localPath,
|
|
46
|
+
worktreePaths: project.worktreePaths,
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
let discoveredProjects: DiscoveredProject[] = []
|
|
50
|
+
|
|
51
|
+
async function refreshDiscovery() {
|
|
52
|
+
discoveredProjects = discoverProjects().filter((project) => !store.isProjectHidden(project.repoKey))
|
|
53
|
+
return discoveredProjects
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
await refreshDiscovery()
|
|
57
|
+
|
|
58
|
+
let server: ReturnType<typeof Bun.serve<ClientState>>
|
|
59
|
+
let router: ReturnType<typeof createWsRouter>
|
|
60
|
+
let agentSnapshotBroadcastTimer: ReturnType<typeof setTimeout> | null = null
|
|
61
|
+
const terminals = new TerminalManager()
|
|
62
|
+
const git = new GitManager()
|
|
63
|
+
const keybindings = new KeybindingsManager()
|
|
64
|
+
await keybindings.initialize()
|
|
65
|
+
const providerSettings = new ProviderSettingsManager()
|
|
66
|
+
await providerSettings.initialize()
|
|
67
|
+
const themeSettings = new ThemeSettingsManager()
|
|
68
|
+
await themeSettings.initialize()
|
|
69
|
+
const updateManager = options.update
|
|
70
|
+
? new UpdateManager({
|
|
71
|
+
currentVersion: options.update.version,
|
|
72
|
+
fetchLatestVersion: options.update.fetchLatestVersion,
|
|
73
|
+
installVersion: options.update.installVersion,
|
|
74
|
+
devMode: getRuntimeProfile() === "dev",
|
|
75
|
+
})
|
|
76
|
+
: null
|
|
77
|
+
const agent = new AgentCoordinator({
|
|
78
|
+
store,
|
|
79
|
+
onStateChange: () => {
|
|
80
|
+
if (agentSnapshotBroadcastTimer) return
|
|
81
|
+
agentSnapshotBroadcastTimer = setTimeout(() => {
|
|
82
|
+
agentSnapshotBroadcastTimer = null
|
|
83
|
+
router.broadcastSnapshots()
|
|
84
|
+
}, 33)
|
|
85
|
+
},
|
|
86
|
+
attachmentsDir: path.join(store.dataDir, "attachments"),
|
|
87
|
+
})
|
|
88
|
+
router = createWsRouter({
|
|
89
|
+
store,
|
|
90
|
+
agent,
|
|
91
|
+
terminals,
|
|
92
|
+
git,
|
|
93
|
+
keybindings,
|
|
94
|
+
providerSettings,
|
|
95
|
+
themeSettings,
|
|
96
|
+
refreshDiscovery,
|
|
97
|
+
getDiscoveredProjects: () => discoveredProjects,
|
|
98
|
+
machineDisplayName,
|
|
99
|
+
updateManager,
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
const distDir = path.join(import.meta.dir, "..", "..", "dist", "client")
|
|
103
|
+
|
|
104
|
+
const MAX_PORT_ATTEMPTS = 20
|
|
105
|
+
let actualPort = port
|
|
106
|
+
|
|
107
|
+
for (let attempt = 0; attempt < MAX_PORT_ATTEMPTS; attempt++) {
|
|
108
|
+
try {
|
|
109
|
+
server = Bun.serve<ClientState>({
|
|
110
|
+
port: actualPort,
|
|
111
|
+
hostname,
|
|
112
|
+
fetch(req, serverInstance) {
|
|
113
|
+
const url = new URL(req.url)
|
|
114
|
+
|
|
115
|
+
if (url.pathname === "/ws") {
|
|
116
|
+
const upgraded = serverInstance.upgrade(req, {
|
|
117
|
+
data: {
|
|
118
|
+
subscriptions: new Map(),
|
|
119
|
+
},
|
|
120
|
+
})
|
|
121
|
+
return upgraded ? undefined : new Response("WebSocket upgrade failed", { status: 400 })
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (url.pathname === "/health") {
|
|
125
|
+
return Response.json({ ok: true, port: actualPort })
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (url.pathname === "/api/directories") {
|
|
129
|
+
return serveDirectories(url)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (url.pathname === "/api/backgrounds") {
|
|
133
|
+
return getSystemBackgrounds().then(Response.json)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (url.pathname.startsWith("/api/backgrounds/")) {
|
|
137
|
+
const id = url.pathname.slice("/api/backgrounds/".length)
|
|
138
|
+
return resolveBackgroundPath(id).then(filePath => {
|
|
139
|
+
if (filePath) return new Response(Bun.file(filePath))
|
|
140
|
+
return new Response("Not found", { status: 404 })
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (url.pathname.startsWith(`${ATTACHMENTS_ROUTE_PREFIX}/`)) {
|
|
145
|
+
return serveAttachment(path.join(store.dataDir, "attachments"), url.pathname)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return serveStatic(distDir, url.pathname)
|
|
149
|
+
},
|
|
150
|
+
websocket: {
|
|
151
|
+
open(ws) {
|
|
152
|
+
router.handleOpen(ws)
|
|
153
|
+
},
|
|
154
|
+
message(ws, raw) {
|
|
155
|
+
router.handleMessage(ws, raw)
|
|
156
|
+
},
|
|
157
|
+
close(ws) {
|
|
158
|
+
router.handleClose(ws)
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
})
|
|
162
|
+
break
|
|
163
|
+
} catch (err: unknown) {
|
|
164
|
+
const isAddrInUse =
|
|
165
|
+
err instanceof Error && "code" in err && (err as NodeJS.ErrnoException).code === "EADDRINUSE"
|
|
166
|
+
if (!isAddrInUse || strictPort || attempt === MAX_PORT_ATTEMPTS - 1) {
|
|
167
|
+
throw err
|
|
168
|
+
}
|
|
169
|
+
console.log(`Port ${actualPort} is in use, trying ${actualPort + 1}...`)
|
|
170
|
+
actualPort++
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const shutdown = async () => {
|
|
175
|
+
for (const chatId of [...agent.activeTurns.keys()]) {
|
|
176
|
+
await agent.shutdown(chatId)
|
|
177
|
+
}
|
|
178
|
+
router.dispose()
|
|
179
|
+
keybindings.dispose()
|
|
180
|
+
providerSettings.dispose()
|
|
181
|
+
themeSettings.dispose()
|
|
182
|
+
terminals.closeAll()
|
|
183
|
+
await store.compact()
|
|
184
|
+
server.stop(true)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
port: actualPort,
|
|
189
|
+
store,
|
|
190
|
+
updateManager,
|
|
191
|
+
stop: shutdown,
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function serveAttachment(attachmentsDir: string, pathname: string) {
|
|
196
|
+
const relativePath = pathname.slice(`${ATTACHMENTS_ROUTE_PREFIX}/`.length)
|
|
197
|
+
const filePath = resolveAttachmentPath(attachmentsDir, decodeURIComponent(relativePath))
|
|
198
|
+
if (!filePath) {
|
|
199
|
+
return new Response("Invalid attachment path", { status: 400 })
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const file = Bun.file(filePath)
|
|
203
|
+
if (!(await file.exists())) {
|
|
204
|
+
return new Response("Not Found", { status: 404 })
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return new Response(file)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function serveDirectories(url: URL) {
|
|
211
|
+
try {
|
|
212
|
+
const localPath = url.searchParams.get("path") ?? undefined
|
|
213
|
+
const snapshot = await listProjectDirectories(localPath)
|
|
214
|
+
return Response.json(snapshot)
|
|
215
|
+
} catch (error) {
|
|
216
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
217
|
+
return Response.json({ error: message }, { status: 400 })
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function serveStatic(distDir: string, pathname: string) {
|
|
222
|
+
const requestedPath = pathname === "/" ? "/index.html" : pathname
|
|
223
|
+
const filePath = path.join(distDir, requestedPath)
|
|
224
|
+
const indexPath = path.join(distDir, "index.html")
|
|
225
|
+
|
|
226
|
+
const file = Bun.file(filePath)
|
|
227
|
+
if (await file.exists()) {
|
|
228
|
+
return new Response(file)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const indexFile = Bun.file(indexPath)
|
|
232
|
+
if (await indexFile.exists()) {
|
|
233
|
+
return new Response(indexFile, {
|
|
234
|
+
headers: {
|
|
235
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
236
|
+
},
|
|
237
|
+
})
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return new Response(
|
|
241
|
+
`${APP_NAME} client bundle not found. Run \`bun run build\` inside workbench/ first.`,
|
|
242
|
+
{ status: 503 }
|
|
243
|
+
)
|
|
244
|
+
}
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import path from "node:path"
|
|
2
|
+
import process from "node:process"
|
|
3
|
+
import defaultShell, { detectDefaultShell } from "default-shell"
|
|
4
|
+
import { Terminal } from "@xterm/headless"
|
|
5
|
+
import { SerializeAddon } from "@xterm/addon-serialize"
|
|
6
|
+
import type { TerminalEvent, TerminalSnapshot } from "../shared/protocol"
|
|
7
|
+
|
|
8
|
+
const DEFAULT_COLS = 80
|
|
9
|
+
const DEFAULT_ROWS = 24
|
|
10
|
+
const DEFAULT_SCROLLBACK = 1_000
|
|
11
|
+
const MIN_SCROLLBACK = 500
|
|
12
|
+
const MAX_SCROLLBACK = 5_000
|
|
13
|
+
const FOCUS_IN_SEQUENCE = "\x1b[I"
|
|
14
|
+
const FOCUS_OUT_SEQUENCE = "\x1b[O"
|
|
15
|
+
const MODE_SEQUENCE_TAIL_LENGTH = 16
|
|
16
|
+
|
|
17
|
+
interface CreateTerminalArgs {
|
|
18
|
+
projectPath: string
|
|
19
|
+
terminalId: string
|
|
20
|
+
cols: number
|
|
21
|
+
rows: number
|
|
22
|
+
scrollback: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface TerminalSession {
|
|
26
|
+
terminalId: string
|
|
27
|
+
title: string
|
|
28
|
+
cwd: string
|
|
29
|
+
shell: string
|
|
30
|
+
cols: number
|
|
31
|
+
rows: number
|
|
32
|
+
scrollback: number
|
|
33
|
+
status: "running" | "exited"
|
|
34
|
+
exitCode: number | null
|
|
35
|
+
process: Bun.Subprocess | null
|
|
36
|
+
terminal: Bun.Terminal
|
|
37
|
+
headless: Terminal
|
|
38
|
+
serializeAddon: SerializeAddon
|
|
39
|
+
focusReportingEnabled: boolean
|
|
40
|
+
modeSequenceTail: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function clampScrollback(value: number) {
|
|
44
|
+
if (!Number.isFinite(value)) return DEFAULT_SCROLLBACK
|
|
45
|
+
return Math.min(MAX_SCROLLBACK, Math.max(MIN_SCROLLBACK, Math.round(value)))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function normalizeTerminalDimension(value: number, fallback: number) {
|
|
49
|
+
if (!Number.isFinite(value)) return fallback
|
|
50
|
+
return Math.max(1, Math.round(value))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function resolveShell() {
|
|
54
|
+
try {
|
|
55
|
+
return detectDefaultShell()
|
|
56
|
+
} catch {
|
|
57
|
+
if (defaultShell) return defaultShell
|
|
58
|
+
if (process.platform === "win32") {
|
|
59
|
+
return process.env.ComSpec || "cmd.exe"
|
|
60
|
+
}
|
|
61
|
+
return process.env.SHELL || "/bin/sh"
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function resolveShellArgs(shellPath: string) {
|
|
66
|
+
if (process.platform === "win32") {
|
|
67
|
+
return []
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const shellName = path.basename(shellPath)
|
|
71
|
+
if (["bash", "zsh", "fish", "sh", "ksh"].includes(shellName)) {
|
|
72
|
+
return ["-l"]
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return []
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function createTerminalEnv() {
|
|
79
|
+
return {
|
|
80
|
+
...process.env,
|
|
81
|
+
TERM: "xterm-256color",
|
|
82
|
+
COLORTERM: "truecolor",
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function updateFocusReportingState(session: Pick<TerminalSession, "focusReportingEnabled" | "modeSequenceTail">, chunk: string) {
|
|
87
|
+
const combined = session.modeSequenceTail + chunk
|
|
88
|
+
const regex = /\x1b\[\?1004([hl])/g
|
|
89
|
+
|
|
90
|
+
for (const match of combined.matchAll(regex)) {
|
|
91
|
+
session.focusReportingEnabled = match[1] === "h"
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
session.modeSequenceTail = combined.slice(-MODE_SEQUENCE_TAIL_LENGTH)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function filterFocusReportInput(data: string, allowFocusReporting: boolean) {
|
|
98
|
+
if (allowFocusReporting) {
|
|
99
|
+
return data
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return data.replaceAll(FOCUS_IN_SEQUENCE, "").replaceAll(FOCUS_OUT_SEQUENCE, "")
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function killTerminalProcessTree(subprocess: Bun.Subprocess | null) {
|
|
106
|
+
if (!subprocess) return
|
|
107
|
+
|
|
108
|
+
const pid = subprocess.pid
|
|
109
|
+
if (typeof pid !== "number") return
|
|
110
|
+
|
|
111
|
+
if (process.platform !== "win32") {
|
|
112
|
+
try {
|
|
113
|
+
process.kill(-pid, "SIGKILL")
|
|
114
|
+
return
|
|
115
|
+
} catch {
|
|
116
|
+
// Fall back to killing only the shell process if group termination fails.
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
subprocess.kill("SIGKILL")
|
|
122
|
+
} catch {
|
|
123
|
+
// Ignore subprocess shutdown errors during disposal.
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function signalTerminalProcessGroup(subprocess: Bun.Subprocess | null, signal: NodeJS.Signals) {
|
|
128
|
+
if (!subprocess) return false
|
|
129
|
+
|
|
130
|
+
const pid = subprocess.pid
|
|
131
|
+
if (typeof pid !== "number") return false
|
|
132
|
+
|
|
133
|
+
if (process.platform !== "win32") {
|
|
134
|
+
try {
|
|
135
|
+
process.kill(-pid, signal)
|
|
136
|
+
return true
|
|
137
|
+
} catch {
|
|
138
|
+
// Fall back to signaling only the shell if group signaling fails.
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
subprocess.kill(signal)
|
|
144
|
+
return true
|
|
145
|
+
} catch {
|
|
146
|
+
return false
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export class TerminalManager {
|
|
151
|
+
private readonly sessions = new Map<string, TerminalSession>()
|
|
152
|
+
private readonly listeners = new Set<(event: TerminalEvent) => void>()
|
|
153
|
+
|
|
154
|
+
onEvent(listener: (event: TerminalEvent) => void) {
|
|
155
|
+
this.listeners.add(listener)
|
|
156
|
+
return () => {
|
|
157
|
+
this.listeners.delete(listener)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
createTerminal(args: CreateTerminalArgs) {
|
|
162
|
+
if (process.platform === "win32") {
|
|
163
|
+
throw new Error("Embedded terminal is currently supported on macOS/Linux only.")
|
|
164
|
+
}
|
|
165
|
+
if (typeof Bun.Terminal !== "function") {
|
|
166
|
+
throw new Error("Embedded terminal requires Bun 1.3.5+ with Bun.Terminal support.")
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const existing = this.sessions.get(args.terminalId)
|
|
170
|
+
if (existing) {
|
|
171
|
+
existing.scrollback = clampScrollback(args.scrollback)
|
|
172
|
+
existing.cols = normalizeTerminalDimension(args.cols, existing.cols)
|
|
173
|
+
existing.rows = normalizeTerminalDimension(args.rows, existing.rows)
|
|
174
|
+
existing.headless.options.scrollback = existing.scrollback
|
|
175
|
+
existing.headless.resize(existing.cols, existing.rows)
|
|
176
|
+
existing.terminal.resize(existing.cols, existing.rows)
|
|
177
|
+
signalTerminalProcessGroup(existing.process, "SIGWINCH")
|
|
178
|
+
return this.snapshotOf(existing)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const shell = resolveShell()
|
|
182
|
+
const cols = normalizeTerminalDimension(args.cols, DEFAULT_COLS)
|
|
183
|
+
const rows = normalizeTerminalDimension(args.rows, DEFAULT_ROWS)
|
|
184
|
+
const scrollback = clampScrollback(args.scrollback)
|
|
185
|
+
const title = path.basename(shell) || "shell"
|
|
186
|
+
const headless = new Terminal({ cols, rows, scrollback, allowProposedApi: true })
|
|
187
|
+
const serializeAddon = new SerializeAddon()
|
|
188
|
+
headless.loadAddon(serializeAddon)
|
|
189
|
+
|
|
190
|
+
const session: TerminalSession = {
|
|
191
|
+
terminalId: args.terminalId,
|
|
192
|
+
title,
|
|
193
|
+
cwd: args.projectPath,
|
|
194
|
+
shell,
|
|
195
|
+
cols,
|
|
196
|
+
rows,
|
|
197
|
+
scrollback,
|
|
198
|
+
status: "running",
|
|
199
|
+
exitCode: null,
|
|
200
|
+
process: null,
|
|
201
|
+
terminal: new Bun.Terminal({
|
|
202
|
+
cols,
|
|
203
|
+
rows,
|
|
204
|
+
name: "xterm-256color",
|
|
205
|
+
data: (_terminal, data) => {
|
|
206
|
+
const chunk = Buffer.from(data).toString("utf8")
|
|
207
|
+
updateFocusReportingState(session, chunk)
|
|
208
|
+
headless.write(chunk)
|
|
209
|
+
this.emit({
|
|
210
|
+
type: "terminal.output",
|
|
211
|
+
terminalId: args.terminalId,
|
|
212
|
+
data: chunk,
|
|
213
|
+
})
|
|
214
|
+
},
|
|
215
|
+
}),
|
|
216
|
+
headless,
|
|
217
|
+
serializeAddon,
|
|
218
|
+
focusReportingEnabled: false,
|
|
219
|
+
modeSequenceTail: "",
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
try {
|
|
223
|
+
session.process = Bun.spawn([shell, ...resolveShellArgs(shell)], {
|
|
224
|
+
cwd: args.projectPath,
|
|
225
|
+
env: createTerminalEnv(),
|
|
226
|
+
terminal: session.terminal,
|
|
227
|
+
})
|
|
228
|
+
} catch (error) {
|
|
229
|
+
session.terminal.close()
|
|
230
|
+
session.serializeAddon.dispose()
|
|
231
|
+
session.headless.dispose()
|
|
232
|
+
throw error
|
|
233
|
+
}
|
|
234
|
+
void session.process.exited.then((exitCode) => {
|
|
235
|
+
const active = this.sessions.get(args.terminalId)
|
|
236
|
+
if (!active) return
|
|
237
|
+
active.status = "exited"
|
|
238
|
+
active.exitCode = exitCode
|
|
239
|
+
this.emit({
|
|
240
|
+
type: "terminal.exit",
|
|
241
|
+
terminalId: args.terminalId,
|
|
242
|
+
exitCode,
|
|
243
|
+
})
|
|
244
|
+
}).catch((error) => {
|
|
245
|
+
const active = this.sessions.get(args.terminalId)
|
|
246
|
+
if (!active) return
|
|
247
|
+
active.status = "exited"
|
|
248
|
+
active.exitCode = 1
|
|
249
|
+
this.emit({
|
|
250
|
+
type: "terminal.output",
|
|
251
|
+
terminalId: args.terminalId,
|
|
252
|
+
data: `\r\n[terminal error] ${error instanceof Error ? error.message : String(error)}\r\n`,
|
|
253
|
+
})
|
|
254
|
+
this.emit({
|
|
255
|
+
type: "terminal.exit",
|
|
256
|
+
terminalId: args.terminalId,
|
|
257
|
+
exitCode: 1,
|
|
258
|
+
})
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
this.sessions.set(args.terminalId, session)
|
|
262
|
+
return this.snapshotOf(session)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
getSnapshot(terminalId: string): TerminalSnapshot | null {
|
|
266
|
+
const session = this.sessions.get(terminalId)
|
|
267
|
+
return session ? this.snapshotOf(session) : null
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
write(terminalId: string, data: string) {
|
|
271
|
+
const session = this.sessions.get(terminalId)
|
|
272
|
+
if (!session || session.status === "exited") return
|
|
273
|
+
|
|
274
|
+
const filteredData = filterFocusReportInput(data, session.focusReportingEnabled)
|
|
275
|
+
if (!filteredData) return
|
|
276
|
+
|
|
277
|
+
let cursor = 0
|
|
278
|
+
|
|
279
|
+
while (cursor < filteredData.length) {
|
|
280
|
+
const ctrlCIndex = filteredData.indexOf("\x03", cursor)
|
|
281
|
+
|
|
282
|
+
if (ctrlCIndex === -1) {
|
|
283
|
+
session.terminal.write(filteredData.slice(cursor))
|
|
284
|
+
return
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (ctrlCIndex > cursor) {
|
|
288
|
+
session.terminal.write(filteredData.slice(cursor, ctrlCIndex))
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
signalTerminalProcessGroup(session.process, "SIGINT")
|
|
292
|
+
cursor = ctrlCIndex + 1
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
resize(terminalId: string, cols: number, rows: number) {
|
|
297
|
+
const session = this.sessions.get(terminalId)
|
|
298
|
+
if (!session) return
|
|
299
|
+
session.cols = normalizeTerminalDimension(cols, session.cols)
|
|
300
|
+
session.rows = normalizeTerminalDimension(rows, session.rows)
|
|
301
|
+
session.headless.resize(session.cols, session.rows)
|
|
302
|
+
session.terminal.resize(session.cols, session.rows)
|
|
303
|
+
signalTerminalProcessGroup(session.process, "SIGWINCH")
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
close(terminalId: string) {
|
|
307
|
+
const session = this.sessions.get(terminalId)
|
|
308
|
+
if (!session) return
|
|
309
|
+
|
|
310
|
+
this.sessions.delete(terminalId)
|
|
311
|
+
killTerminalProcessTree(session.process)
|
|
312
|
+
session.terminal.close()
|
|
313
|
+
session.serializeAddon.dispose()
|
|
314
|
+
session.headless.dispose()
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
closeByCwd(cwd: string) {
|
|
318
|
+
for (const [terminalId, session] of this.sessions.entries()) {
|
|
319
|
+
if (session.cwd !== cwd) continue
|
|
320
|
+
this.close(terminalId)
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
closeAll() {
|
|
325
|
+
for (const terminalId of this.sessions.keys()) {
|
|
326
|
+
this.close(terminalId)
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
private snapshotOf(session: TerminalSession): TerminalSnapshot {
|
|
331
|
+
return {
|
|
332
|
+
terminalId: session.terminalId,
|
|
333
|
+
title: session.title,
|
|
334
|
+
cwd: session.cwd,
|
|
335
|
+
shell: session.shell,
|
|
336
|
+
cols: session.cols,
|
|
337
|
+
rows: session.rows,
|
|
338
|
+
scrollback: session.scrollback,
|
|
339
|
+
serializedState: session.serializeAddon.serialize({ scrollback: session.scrollback }),
|
|
340
|
+
status: session.status,
|
|
341
|
+
exitCode: session.exitCode,
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
private emit(event: TerminalEvent) {
|
|
346
|
+
for (const listener of this.listeners) {
|
|
347
|
+
listener(event)
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|