@vladimirven/openswe 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.
Files changed (117) hide show
  1. package/AGENTS.md +203 -0
  2. package/CLAUDE.md +203 -0
  3. package/README.md +166 -0
  4. package/bun.lock +447 -0
  5. package/bunfig.toml +4 -0
  6. package/package.json +42 -0
  7. package/src/app.tsx +84 -0
  8. package/src/components/App.tsx +526 -0
  9. package/src/components/ConfirmDialog.tsx +88 -0
  10. package/src/components/Footer.tsx +50 -0
  11. package/src/components/HelpModal.tsx +136 -0
  12. package/src/components/IssueSelectorModal.tsx +701 -0
  13. package/src/components/ManualSessionModal.tsx +191 -0
  14. package/src/components/PhaseProgress.tsx +45 -0
  15. package/src/components/Preview.tsx +249 -0
  16. package/src/components/ProviderSwitcherModal.tsx +156 -0
  17. package/src/components/ScrollableText.tsx +120 -0
  18. package/src/components/SessionCard.tsx +60 -0
  19. package/src/components/SessionList.tsx +79 -0
  20. package/src/components/SessionTerminal.tsx +89 -0
  21. package/src/components/StatusBar.tsx +84 -0
  22. package/src/components/ThemeSwitcherModal.tsx +237 -0
  23. package/src/components/index.ts +58 -0
  24. package/src/components/session-utils.ts +337 -0
  25. package/src/components/theme.ts +206 -0
  26. package/src/components/types.ts +215 -0
  27. package/src/config/defaults.ts +44 -0
  28. package/src/config/env.ts +67 -0
  29. package/src/config/global.ts +252 -0
  30. package/src/config/index.ts +171 -0
  31. package/src/config/types.ts +131 -0
  32. package/src/core/.gitkeep +0 -0
  33. package/src/core/index.ts +5 -0
  34. package/src/core/parser.ts +62 -0
  35. package/src/core/process-manager.ts +52 -0
  36. package/src/core/session.ts +423 -0
  37. package/src/core/tmux.ts +206 -0
  38. package/src/git/.gitkeep +0 -0
  39. package/src/git/index.ts +8 -0
  40. package/src/git/repo.ts +443 -0
  41. package/src/git/worktree.ts +317 -0
  42. package/src/github/.gitkeep +0 -0
  43. package/src/github/client.ts +208 -0
  44. package/src/github/index.ts +8 -0
  45. package/src/github/issues.ts +351 -0
  46. package/src/index.ts +369 -0
  47. package/src/prompts/.gitkeep +0 -0
  48. package/src/prompts/index.ts +1 -0
  49. package/src/prompts/swe-system.ts +22 -0
  50. package/src/providers/claude.ts +103 -0
  51. package/src/providers/index.ts +21 -0
  52. package/src/providers/opencode.ts +98 -0
  53. package/src/providers/registry.ts +53 -0
  54. package/src/providers/types.ts +117 -0
  55. package/src/store/buffers.ts +234 -0
  56. package/src/store/db.test.ts +579 -0
  57. package/src/store/db.ts +249 -0
  58. package/src/store/index.ts +101 -0
  59. package/src/store/project.ts +119 -0
  60. package/src/store/schema.sql +71 -0
  61. package/src/store/sessions.ts +454 -0
  62. package/src/store/types.ts +194 -0
  63. package/src/theme/context.tsx +170 -0
  64. package/src/theme/custom.ts +134 -0
  65. package/src/theme/index.ts +58 -0
  66. package/src/theme/loader.ts +264 -0
  67. package/src/theme/themes/aura.json +69 -0
  68. package/src/theme/themes/ayu.json +80 -0
  69. package/src/theme/themes/carbonfox.json +248 -0
  70. package/src/theme/themes/catppuccin-frappe.json +233 -0
  71. package/src/theme/themes/catppuccin-macchiato.json +233 -0
  72. package/src/theme/themes/catppuccin.json +112 -0
  73. package/src/theme/themes/cobalt2.json +228 -0
  74. package/src/theme/themes/cursor.json +249 -0
  75. package/src/theme/themes/dracula.json +219 -0
  76. package/src/theme/themes/everforest.json +241 -0
  77. package/src/theme/themes/flexoki.json +237 -0
  78. package/src/theme/themes/github.json +233 -0
  79. package/src/theme/themes/gruvbox.json +242 -0
  80. package/src/theme/themes/kanagawa.json +77 -0
  81. package/src/theme/themes/lucent-orng.json +237 -0
  82. package/src/theme/themes/material.json +235 -0
  83. package/src/theme/themes/matrix.json +77 -0
  84. package/src/theme/themes/mercury.json +252 -0
  85. package/src/theme/themes/monokai.json +221 -0
  86. package/src/theme/themes/nightowl.json +221 -0
  87. package/src/theme/themes/nord.json +223 -0
  88. package/src/theme/themes/one-dark.json +84 -0
  89. package/src/theme/themes/opencode.json +245 -0
  90. package/src/theme/themes/orng.json +249 -0
  91. package/src/theme/themes/osaka-jade.json +93 -0
  92. package/src/theme/themes/palenight.json +222 -0
  93. package/src/theme/themes/rosepine.json +234 -0
  94. package/src/theme/themes/solarized.json +223 -0
  95. package/src/theme/themes/synthwave84.json +226 -0
  96. package/src/theme/themes/tokyonight.json +243 -0
  97. package/src/theme/themes/vercel.json +245 -0
  98. package/src/theme/themes/vesper.json +218 -0
  99. package/src/theme/themes/zenburn.json +223 -0
  100. package/src/theme/types.ts +225 -0
  101. package/src/types/sql.d.ts +4 -0
  102. package/src/utils/ansi-parser.ts +225 -0
  103. package/src/utils/format.ts +46 -0
  104. package/src/utils/id.ts +15 -0
  105. package/src/utils/logger.ts +112 -0
  106. package/src/utils/prerequisites.ts +118 -0
  107. package/src/utils/shell.ts +9 -0
  108. package/src/wizard/flows.ts +419 -0
  109. package/src/wizard/index.ts +37 -0
  110. package/src/wizard/prompts.ts +190 -0
  111. package/src/workspace/detect.test.ts +51 -0
  112. package/src/workspace/detect.ts +223 -0
  113. package/src/workspace/index.ts +71 -0
  114. package/src/workspace/init.ts +131 -0
  115. package/src/workspace/paths.ts +143 -0
  116. package/src/workspace/project.ts +164 -0
  117. package/tsconfig.json +22 -0
@@ -0,0 +1,423 @@
1
+ /**
2
+ * Session lifecycle manager (Tmux Backend)
3
+ *
4
+ * Coordinates Tmux sessions, output parsing, and state transitions.
5
+ * Handles session completion.
6
+ */
7
+
8
+ import {
9
+ getSession,
10
+ getSessionsByStatus,
11
+ updateSessionStatus,
12
+ updateSessionPhase,
13
+ incrementRetryCount,
14
+ setPid,
15
+ setAISessionData,
16
+ setLines,
17
+ isValidPhase,
18
+ getProject,
19
+ } from "../store"
20
+ import type { AISessionData } from "../store"
21
+ import type { GlobalConfig, AIBackend } from "../config"
22
+ import { createParser } from "./parser"
23
+ import type { ParsedEvent } from "./parser"
24
+ import { TmuxManager } from "./tmux"
25
+ import { logger } from "../utils/logger"
26
+ import { getSessionLogPath } from "../workspace/paths"
27
+ import { dirname } from "path"
28
+ import { mkdir, open } from "fs/promises"
29
+ import { getProvider } from "../providers"
30
+ import type { Provider } from "../providers"
31
+
32
+ export interface StartSessionOptions {
33
+ sessionId: string
34
+ prompt?: string
35
+ resumeSessionId?: string
36
+ aiSessionData?: AISessionData | null
37
+ }
38
+
39
+ interface ActiveSession {
40
+ stopTail: () => void
41
+ logPath: string
42
+ }
43
+
44
+ export class SessionManager {
45
+ private config: GlobalConfig
46
+ private processManager: TmuxManager
47
+ private activeSessions: Map<string, ActiveSession>
48
+ private projectRoot: string
49
+ private provider: Provider
50
+ private parseOutputLine: (line: string) => ParsedEvent | null
51
+
52
+ private static readonly ANSI_ESCAPE_REGEX = /\x1b\[[0-9;]*[A-Za-z]/g
53
+ private static readonly ANSI_OSC_REGEX = /\x1b\][^\x07]*(?:\x07|\x1b\\)/g
54
+
55
+ constructor(
56
+ config: GlobalConfig,
57
+ projectRoot: string,
58
+ processManager?: TmuxManager
59
+ ) {
60
+ this.config = config
61
+ this.projectRoot = projectRoot
62
+ this.processManager = processManager ?? new TmuxManager()
63
+ this.activeSessions = new Map()
64
+
65
+ // Initialize provider based on config
66
+ this.provider = getProvider(config.ai.backend)
67
+ this.parseOutputLine = createParser(this.provider.parserPatterns)
68
+
69
+ // Start background poller for session health/exit detection
70
+ setInterval(() => this.checkSessionHealth(), 2000)
71
+ }
72
+
73
+ /**
74
+ * Get the current AI provider
75
+ */
76
+ getProvider(): Provider {
77
+ return this.provider
78
+ }
79
+
80
+ /**
81
+ * Switch to a different AI provider at runtime
82
+ * @param backend - The backend to switch to
83
+ */
84
+ setProvider(backend: AIBackend): void {
85
+ this.provider = getProvider(backend)
86
+ this.parseOutputLine = createParser(this.provider.parserPatterns)
87
+ this.config.ai.backend = backend
88
+ }
89
+
90
+ /**
91
+ * Recover sessions that were active/running
92
+ *
93
+ * Handles edge cases:
94
+ * - Sessions marked "active" in DB but not in tmux → mark as "paused"
95
+ * - Tmux sessions running but session marked "paused" → re-mark as "active"
96
+ * - Log orphaned tmux sessions for manual cleanup
97
+ */
98
+ async recoverSessions(): Promise<void> {
99
+ const activeIds = await this.processManager.listActiveSessions()
100
+ const dbActiveSessions = getSessionsByStatus("active")
101
+ const dbPausedSessions = getSessionsByStatus("paused")
102
+
103
+ // Track which tmux sessions we've matched to DB sessions
104
+ const matchedTmuxIds = new Set<string>()
105
+
106
+ // 1. Handle sessions marked "active" in DB
107
+ for (const session of dbActiveSessions) {
108
+ if (activeIds.includes(session.id)) {
109
+ // DB active + tmux running → recover (attach log tail)
110
+ logger.info(`Recovering active session "${session.name}" (found in tmux)`)
111
+ await this.attachLogTail(session.id)
112
+ matchedTmuxIds.add(session.id)
113
+ } else {
114
+ // DB active + tmux not running → crashed/finished while app was closed
115
+ logger.warn(`Session "${session.name}" marked active but not found in tmux. Marking as paused.`)
116
+ updateSessionStatus(session.id, "paused")
117
+ setPid(session.id, null)
118
+ }
119
+ }
120
+
121
+ // 2. Handle sessions marked "paused" in DB but still running in tmux
122
+ for (const session of dbPausedSessions) {
123
+ if (activeIds.includes(session.id)) {
124
+ // DB paused + tmux running → re-activate
125
+ logger.info(`Session "${session.name}" found running in tmux (was marked paused). Re-activating.`)
126
+ updateSessionStatus(session.id, "active")
127
+ await this.attachLogTail(session.id)
128
+ matchedTmuxIds.add(session.id)
129
+ }
130
+ }
131
+
132
+ // 3. Log orphaned tmux sessions (running in tmux but no matching DB session)
133
+ for (const tmuxId of activeIds) {
134
+ if (!matchedTmuxIds.has(tmuxId)) {
135
+ const session = getSession(tmuxId)
136
+ if (!session) {
137
+ logger.warn(`Orphaned tmux session found: openswe-${tmuxId} (no DB record). Consider killing it manually with: tmux kill-session -t openswe-${tmuxId}`)
138
+ }
139
+ }
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Check if active sessions are still running
145
+ */
146
+ private async checkSessionHealth() {
147
+ for (const [sessionId, sessionData] of this.activeSessions) {
148
+ const isRunning = await this.processManager.isRunning(sessionId)
149
+ if (!isRunning) {
150
+ // Process exited!
151
+ this.handleExit(sessionId)
152
+ }
153
+ }
154
+ }
155
+
156
+ async startSession(options: StartSessionOptions): Promise<void> {
157
+ const session = getSession(options.sessionId)
158
+ if (!session) {
159
+ throw new Error(`Session not found: ${options.sessionId}`)
160
+ }
161
+
162
+ try {
163
+ updateSessionStatus(session.id, "active")
164
+
165
+ // Auto-transition phase: planning if prompt-driven, working if interactive
166
+ updateSessionPhase(session.id, options.prompt ? "planning" : "working")
167
+
168
+ if (options.aiSessionData !== undefined) {
169
+ setAISessionData(session.id, options.aiSessionData)
170
+ }
171
+
172
+ const logPath = getSessionLogPath(this.projectRoot, session.id)
173
+ await this.ensureLogFile(logPath)
174
+
175
+ // Use session's backend if available, otherwise fall back to global config
176
+ const backend = options.aiSessionData?.backend ?? session.aiSessionData?.backend ?? this.config.ai.backend
177
+ const provider = getProvider(backend)
178
+ const providerConfig = this.config.ai[backend] as unknown as Record<string, unknown>
179
+
180
+ // Build spawn command using the session's provider
181
+ const spawnCmd = provider.buildSpawnCommand(
182
+ session,
183
+ options.prompt,
184
+ options.resumeSessionId,
185
+ providerConfig
186
+ )
187
+
188
+ // Filter env to remove undefined values
189
+ const baseEnv = Object.fromEntries(
190
+ Object.entries(process.env).filter(([, v]) => v !== undefined)
191
+ ) as Record<string, string>
192
+
193
+ // Merge provider-specific env vars
194
+ const env = { ...baseEnv, ...spawnCmd.env }
195
+
196
+ // Spawn tmux session
197
+ const pid = await this.processManager.spawn(
198
+ session.id,
199
+ spawnCmd.command,
200
+ spawnCmd.args,
201
+ session.worktreePath,
202
+ env,
203
+ logPath
204
+ )
205
+
206
+ setPid(session.id, pid)
207
+
208
+ // Start tailing logs for parsing
209
+ await this.attachLogTail(session.id)
210
+
211
+ } catch (error) {
212
+ updateSessionStatus(session.id, "failed")
213
+ logger.error(`Failed to start session ${session.id}:`, error)
214
+ throw error
215
+ }
216
+ }
217
+
218
+ private async attachLogTail(sessionId: string): Promise<void> {
219
+ const logPath = getSessionLogPath(this.projectRoot, sessionId)
220
+ await this.ensureLogFile(logPath)
221
+
222
+ // Use tail -f to stream logs
223
+ const tailProc = Bun.spawn(["tail", "-f", "-n", "+1", logPath], {
224
+ stdout: "pipe",
225
+ stderr: "pipe"
226
+ })
227
+
228
+ // Consume stream
229
+ const reader = tailProc.stdout.getReader()
230
+ const decoder = new TextDecoder()
231
+ let buffer = ""
232
+
233
+ const readLoop = async () => {
234
+ try {
235
+ while (true) {
236
+ const { done, value } = await reader.read()
237
+ if (done) break
238
+
239
+ buffer += decoder.decode(value, { stream: true })
240
+ const lines = buffer.split("\n")
241
+
242
+ // Process all complete lines
243
+ buffer = lines.pop() || "" // Keep last incomplete line
244
+
245
+ for (const line of lines) {
246
+ this.handleOutputLine(sessionId, line)
247
+ }
248
+ }
249
+ } catch (e) {
250
+ // Tail killed
251
+ }
252
+ }
253
+
254
+ readLoop()
255
+
256
+ const errorReader = tailProc.stderr.getReader()
257
+ const handleError = async () => {
258
+ try {
259
+ const { value } = await errorReader.read()
260
+ if (value) {
261
+ const msg = new TextDecoder().decode(value)
262
+ if (msg.trim()) {
263
+ logger.warn(`Log tail error for ${sessionId}: ${msg.trim()}`)
264
+ }
265
+ }
266
+ } catch {
267
+ // Ignore tail stderr errors
268
+ }
269
+ }
270
+
271
+ handleError()
272
+
273
+ this.activeSessions.set(sessionId, {
274
+ logPath,
275
+ stopTail: () => {
276
+ tailProc.kill()
277
+ }
278
+ })
279
+ }
280
+
281
+ async pauseSession(sessionId: string): Promise<void> {
282
+ await this.processManager.kill(sessionId)
283
+ this.cleanupSession(sessionId)
284
+ updateSessionStatus(sessionId, "paused")
285
+ setPid(sessionId, null)
286
+ }
287
+
288
+ async stopSession(sessionId: string): Promise<void> {
289
+ await this.processManager.kill(sessionId)
290
+ this.cleanupSession(sessionId)
291
+ updateSessionStatus(sessionId, "queued")
292
+ setPid(sessionId, null)
293
+ }
294
+
295
+ /**
296
+ * Get the current visual state of the session for preview
297
+ */
298
+ async getSnapshot(sessionId: string): Promise<string[]> {
299
+ try {
300
+ const snapshot = await this.processManager.getSnapshot(sessionId)
301
+ // Save to DB for persistence/caching if needed?
302
+ // For now just return
303
+ setLines(sessionId, snapshot.lines) // Update DB cache for when we are offline/paused
304
+ return snapshot.lines
305
+ } catch {
306
+ // If session not running, return cached lines from DB
307
+ const session = getSession(sessionId)
308
+ // return session?.outputBuffer ... (need to fetch from OB table)
309
+ return []
310
+ }
311
+ }
312
+
313
+ /**
314
+ * Get command to attach to this session
315
+ */
316
+ getAttachCommand(sessionId: string): string[] {
317
+ return this.processManager.getAttachCommand(sessionId)
318
+ }
319
+
320
+ /**
321
+ * Resize the session terminal
322
+ */
323
+ async resizeSession(sessionId: string, cols: number, rows: number): Promise<void> {
324
+ await this.processManager.resize(sessionId, cols, rows)
325
+ }
326
+
327
+ private cleanupSession(sessionId: string) {
328
+ const active = this.activeSessions.get(sessionId)
329
+ if (active) {
330
+ active.stopTail()
331
+ this.activeSessions.delete(sessionId)
332
+ }
333
+ }
334
+
335
+ private handleOutputLine(sessionId: string, line: string): void {
336
+ const normalizedLine = this.normalizeOutputLine(line)
337
+
338
+ // Parse protocol events for state management using provider-specific patterns
339
+ const event = this.parseOutputLine(normalizedLine)
340
+ if (!event) return
341
+
342
+ switch (event.type) {
343
+ case "working":
344
+ updateSessionPhase(sessionId, "working")
345
+ break
346
+ case "done":
347
+ updateSessionPhase(sessionId, "completed")
348
+ this.handleSessionCompletion(sessionId).catch((err) => {
349
+ logger.error(`Session completion handler failed for ${sessionId}:`, err)
350
+ })
351
+ break
352
+ }
353
+ }
354
+
355
+ private normalizeOutputLine(line: string): string {
356
+ return line
357
+ .replace(SessionManager.ANSI_OSC_REGEX, "")
358
+ .replace(SessionManager.ANSI_ESCAPE_REGEX, "")
359
+ .replace(/\r/g, "")
360
+ }
361
+
362
+ private async ensureLogFile(logPath: string): Promise<void> {
363
+ const logDir = dirname(logPath)
364
+ try {
365
+ await mkdir(logDir, { recursive: true })
366
+ const handle = await open(logPath, "a")
367
+ await handle.close()
368
+ } catch (error) {
369
+ logger.warn(`Failed to initialize log file ${logPath}:`, error)
370
+ }
371
+ }
372
+
373
+ private async handleSessionCompletion(sessionId: string): Promise<void> {
374
+ const session = getSession(sessionId)
375
+ if (!session) return
376
+
377
+ this.completeSession(sessionId)
378
+ logger.info(`Session ${session.name} completed`)
379
+ }
380
+
381
+ private completeSession(sessionId: string) {
382
+ updateSessionPhase(sessionId, "completed")
383
+ updateSessionStatus(sessionId, "completed")
384
+ setPid(sessionId, null)
385
+ // We don't necessarily kill the tmux session immediately?
386
+ // Or do we? If it's done, opencode process likely exited.
387
+ // If not, we should probably leave it for user to inspect until they delete?
388
+ // Let's leave it running if it's still there (e.g. if opencode waits for keypress at end)
389
+ // But usually we want to free resources.
390
+ // For now: Leave it. `handleExit` will clean it up if process exits.
391
+ }
392
+
393
+ private handleExit(sessionId: string): void {
394
+ this.cleanupSession(sessionId)
395
+
396
+ const session = getSession(sessionId)
397
+ if (!session) return
398
+
399
+ if (session.status === "completed" || session.status === "failed") {
400
+ setPid(sessionId, null)
401
+ return
402
+ }
403
+
404
+ // Determine if it was a success or failure exit?
405
+ // We don't have exit code from polling.
406
+ // But if we didn't get [OPENSWE:DONE], it's likely a crash or manual exit.
407
+
408
+ // Check if phase is completed
409
+ if (session.phase === "completed") {
410
+ updateSessionStatus(sessionId, "completed")
411
+ } else {
412
+ const retryCount = incrementRetryCount(sessionId)
413
+ if (retryCount >= 2) {
414
+ updateSessionStatus(sessionId, "failed")
415
+ } else {
416
+ // Auto-restart? Or just queue?
417
+ // Logic says "queued" to retry.
418
+ updateSessionStatus(sessionId, "queued")
419
+ }
420
+ }
421
+ setPid(sessionId, null)
422
+ }
423
+ }
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Tmux Process Manager
3
+ *
4
+ * Manages sessions using tmux.
5
+ * - Sessions are named "openswe-<id>"
6
+ * - Output is piped to a log file for parsing
7
+ * - Visual state is retrieved via capture-pane
8
+ */
9
+
10
+ import type { ProcessManager, ProcessSnapshot } from "./process-manager"
11
+ import { logger } from "../utils/logger"
12
+ import { shellQuote } from "../utils/shell"
13
+
14
+ export class TmuxManager implements ProcessManager {
15
+ private prefix = "openswe-"
16
+ private serverInitialized = false
17
+
18
+ /**
19
+ * Ensure tmux server is running before any operations
20
+ */
21
+ private async ensureServer(): Promise<void> {
22
+ if (this.serverInitialized) return
23
+
24
+ const proc = Bun.spawn(["tmux", "start-server"], {
25
+ stderr: "pipe",
26
+ stdout: "ignore"
27
+ })
28
+ const exitCode = await proc.exited
29
+
30
+ if (exitCode !== 0) {
31
+ const err = await new Response(proc.stderr).text()
32
+ throw new Error(`Failed to start tmux server: ${err}`)
33
+ }
34
+
35
+ this.serverInitialized = true
36
+ }
37
+
38
+ private getSessionName(id: string): string {
39
+ return `${this.prefix}${id}`
40
+ }
41
+
42
+ private getIdFromSessionName(name: string): string | null {
43
+ if (name.startsWith(this.prefix)) {
44
+ return name.slice(this.prefix.length)
45
+ }
46
+ return null
47
+ }
48
+
49
+ async spawn(id: string, command: string, args: string[], cwd: string, env: Record<string, string>, logPath: string): Promise<number> {
50
+ await this.ensureServer()
51
+
52
+ const sessionName = this.getSessionName(id)
53
+
54
+ // Construct the command string safely
55
+ // We use 'script' or simple pipe to capture output while keeping it interactive-ish
56
+ // But opencode is a TUI, so we just want to run it.
57
+ // To capture output for the parser, we use 'pipe' but we also need the TUI to work in tmux.
58
+ // The trick: tmux runs the command. We don't strictly need to pipe purely for the parser if we rely on capture-pane,
59
+ // BUT the parser needs the hidden markers [OPENSWE:...] which might be scrolled off screen.
60
+ // So we DO need to pipe stdout.
61
+
62
+ // Using `tee` might buffer or mess up TUI.
63
+ // Better approach: Let tmux handle the TUI.
64
+ // For parsing markers: We can use `tmux pipe-pane` to stream output to a file!
65
+
66
+ const fullCommand = `${shellQuote(command)} ${args.map(shellQuote).join(" ")}`
67
+
68
+ // 1. Create detached session
69
+ // -d: detached
70
+ // -s: session name
71
+ // -c: working directory
72
+ // -x: width (default 80 is too small for modern screens)
73
+ // -y: height (default 24 is too small)
74
+ const createProc = Bun.spawn([
75
+ "tmux", "new-session",
76
+ "-d",
77
+ "-s", sessionName,
78
+ "-x", "140",
79
+ "-y", "50",
80
+ "-c", cwd,
81
+ // We start with a shell to ensure env vars are loaded if needed, or just run the command
82
+ fullCommand
83
+ ], {
84
+ env: { ...process.env, ...env },
85
+ stderr: "pipe"
86
+ })
87
+
88
+ const createExit = await createProc.exited
89
+ if (createExit !== 0) {
90
+ const err = await new Response(createProc.stderr).text()
91
+ throw new Error(`Failed to create tmux session: ${err}`)
92
+ }
93
+
94
+ // 2. Enable logging (pipe-pane) to capture stream for parser
95
+ // This logs everything printed to the pane to a file
96
+ // Escape the logPath for shell safety (single quotes, escape internal single quotes)
97
+ const escapedLogPath = logPath.replace(/'/g, "'\\''")
98
+ const pipeProc = Bun.spawn([
99
+ "tmux", "pipe-pane",
100
+ "-t", sessionName,
101
+ `cat >> '${escapedLogPath}'` // Use append mode and single quotes for safety
102
+ ], { stderr: "pipe" })
103
+
104
+ const pipeExit = await pipeProc.exited
105
+ if (pipeExit !== 0) {
106
+ const err = await new Response(pipeProc.stderr).text()
107
+ logger.warn(`pipe-pane setup failed for ${sessionName}: ${err}`)
108
+ }
109
+
110
+ // 2.5 Disable status bar to reclaim vertical space
111
+ await Bun.spawn(["tmux", "set-option", "-t", sessionName, "status", "off"], { stderr: "ignore" }).exited
112
+
113
+ // 3. Get PID of the process inside tmux (approximate)
114
+ // tmux list-panes -t session -F "#{pane_pid}"
115
+ const pidProc = Bun.spawn(["tmux", "list-panes", "-t", sessionName, "-F", "#{pane_pid}"], { stdout: "pipe" })
116
+ const pidStr = await new Response(pidProc.stdout).text()
117
+ return parseInt(pidStr.trim(), 10)
118
+ }
119
+
120
+ async isRunning(id: string): Promise<boolean> {
121
+ await this.ensureServer()
122
+
123
+ const sessionName = this.getSessionName(id)
124
+ const proc = Bun.spawn(["tmux", "has-session", "-t", sessionName], { stderr: "ignore" })
125
+ const exitCode = await proc.exited
126
+ return exitCode === 0
127
+ }
128
+
129
+ async kill(id: string): Promise<void> {
130
+ const sessionName = this.getSessionName(id)
131
+ // Kill the session
132
+ const proc = Bun.spawn(["tmux", "kill-session", "-t", sessionName], { stderr: "ignore" })
133
+ await proc.exited
134
+ }
135
+
136
+ async getSnapshot(id: string): Promise<ProcessSnapshot> {
137
+ await this.ensureServer()
138
+
139
+ // Verify session exists first to avoid capture-pane errors
140
+ if (!(await this.isRunning(id))) {
141
+ return { lines: [], cursor: null }
142
+ }
143
+
144
+ const sessionName = this.getSessionName(id)
145
+
146
+ // capture-pane -p (print) -e (include escape sequences for colored preview)
147
+ // The Preview component uses ansi-parser to render colored output
148
+
149
+ const proc = Bun.spawn(["tmux", "capture-pane", "-pet", sessionName], { stdout: "pipe", stderr: "pipe" })
150
+ const exitCode = await proc.exited
151
+
152
+ if (exitCode !== 0) {
153
+ logger.warn(`capture-pane failed for ${sessionName}`)
154
+ return { lines: [], cursor: null }
155
+ }
156
+
157
+ const output = await new Response(proc.stdout).text()
158
+
159
+ // Get lines
160
+ const lines = output.split("\n")
161
+ // Note: We used to trim trailing empty lines here, but that causes the
162
+ // preview to look "short" when the terminal has empty space at the bottom.
163
+ // We now preserve all lines to show the full terminal height.
164
+
165
+ return {
166
+ lines,
167
+ cursor: null // TODO: Could get cursor pos via tmux display-message
168
+ }
169
+ }
170
+
171
+ async sendInput(id: string, data: string): Promise<void> {
172
+ const sessionName = this.getSessionName(id)
173
+ // send-keys is mainly for strings. For control chars it's trickier.
174
+ // simpler approach: just send keys.
175
+ await Bun.spawn(["tmux", "send-keys", "-t", sessionName, data]).exited
176
+ }
177
+
178
+ async listActiveSessions(): Promise<string[]> {
179
+ try {
180
+ await this.ensureServer()
181
+
182
+ // List sessions with format: name
183
+ const proc = Bun.spawn(["tmux", "list-sessions", "-F", "#{session_name}"], { stdout: "pipe", stderr: "ignore" })
184
+ const output = await new Response(proc.stdout).text()
185
+
186
+ const ids: string[] = []
187
+ for (const line of output.split("\n")) {
188
+ const id = this.getIdFromSessionName(line.trim())
189
+ if (id) ids.push(id)
190
+ }
191
+ return ids
192
+ } catch {
193
+ return []
194
+ }
195
+ }
196
+
197
+ getAttachCommand(id: string): string[] {
198
+ return ["tmux", "attach-session", "-t", this.getSessionName(id)]
199
+ }
200
+
201
+ async resize(id: string, cols: number, rows: number): Promise<void> {
202
+ const sessionName = this.getSessionName(id)
203
+ // resize-window sets the size of the window (and thus the detached session)
204
+ await Bun.spawn(["tmux", "resize-window", "-t", sessionName, "-x", cols.toString(), "-y", rows.toString()], { stderr: "ignore" }).exited
205
+ }
206
+ }
File without changes
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Git module
3
+ *
4
+ * Re-exports all git operations for cleaner imports.
5
+ */
6
+
7
+ export * from "./repo"
8
+ export * from "./worktree"