@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.
- package/AGENTS.md +203 -0
- package/CLAUDE.md +203 -0
- package/README.md +166 -0
- package/bun.lock +447 -0
- package/bunfig.toml +4 -0
- package/package.json +42 -0
- package/src/app.tsx +84 -0
- package/src/components/App.tsx +526 -0
- package/src/components/ConfirmDialog.tsx +88 -0
- package/src/components/Footer.tsx +50 -0
- package/src/components/HelpModal.tsx +136 -0
- package/src/components/IssueSelectorModal.tsx +701 -0
- package/src/components/ManualSessionModal.tsx +191 -0
- package/src/components/PhaseProgress.tsx +45 -0
- package/src/components/Preview.tsx +249 -0
- package/src/components/ProviderSwitcherModal.tsx +156 -0
- package/src/components/ScrollableText.tsx +120 -0
- package/src/components/SessionCard.tsx +60 -0
- package/src/components/SessionList.tsx +79 -0
- package/src/components/SessionTerminal.tsx +89 -0
- package/src/components/StatusBar.tsx +84 -0
- package/src/components/ThemeSwitcherModal.tsx +237 -0
- package/src/components/index.ts +58 -0
- package/src/components/session-utils.ts +337 -0
- package/src/components/theme.ts +206 -0
- package/src/components/types.ts +215 -0
- package/src/config/defaults.ts +44 -0
- package/src/config/env.ts +67 -0
- package/src/config/global.ts +252 -0
- package/src/config/index.ts +171 -0
- package/src/config/types.ts +131 -0
- package/src/core/.gitkeep +0 -0
- package/src/core/index.ts +5 -0
- package/src/core/parser.ts +62 -0
- package/src/core/process-manager.ts +52 -0
- package/src/core/session.ts +423 -0
- package/src/core/tmux.ts +206 -0
- package/src/git/.gitkeep +0 -0
- package/src/git/index.ts +8 -0
- package/src/git/repo.ts +443 -0
- package/src/git/worktree.ts +317 -0
- package/src/github/.gitkeep +0 -0
- package/src/github/client.ts +208 -0
- package/src/github/index.ts +8 -0
- package/src/github/issues.ts +351 -0
- package/src/index.ts +369 -0
- package/src/prompts/.gitkeep +0 -0
- package/src/prompts/index.ts +1 -0
- package/src/prompts/swe-system.ts +22 -0
- package/src/providers/claude.ts +103 -0
- package/src/providers/index.ts +21 -0
- package/src/providers/opencode.ts +98 -0
- package/src/providers/registry.ts +53 -0
- package/src/providers/types.ts +117 -0
- package/src/store/buffers.ts +234 -0
- package/src/store/db.test.ts +579 -0
- package/src/store/db.ts +249 -0
- package/src/store/index.ts +101 -0
- package/src/store/project.ts +119 -0
- package/src/store/schema.sql +71 -0
- package/src/store/sessions.ts +454 -0
- package/src/store/types.ts +194 -0
- package/src/theme/context.tsx +170 -0
- package/src/theme/custom.ts +134 -0
- package/src/theme/index.ts +58 -0
- package/src/theme/loader.ts +264 -0
- package/src/theme/themes/aura.json +69 -0
- package/src/theme/themes/ayu.json +80 -0
- package/src/theme/themes/carbonfox.json +248 -0
- package/src/theme/themes/catppuccin-frappe.json +233 -0
- package/src/theme/themes/catppuccin-macchiato.json +233 -0
- package/src/theme/themes/catppuccin.json +112 -0
- package/src/theme/themes/cobalt2.json +228 -0
- package/src/theme/themes/cursor.json +249 -0
- package/src/theme/themes/dracula.json +219 -0
- package/src/theme/themes/everforest.json +241 -0
- package/src/theme/themes/flexoki.json +237 -0
- package/src/theme/themes/github.json +233 -0
- package/src/theme/themes/gruvbox.json +242 -0
- package/src/theme/themes/kanagawa.json +77 -0
- package/src/theme/themes/lucent-orng.json +237 -0
- package/src/theme/themes/material.json +235 -0
- package/src/theme/themes/matrix.json +77 -0
- package/src/theme/themes/mercury.json +252 -0
- package/src/theme/themes/monokai.json +221 -0
- package/src/theme/themes/nightowl.json +221 -0
- package/src/theme/themes/nord.json +223 -0
- package/src/theme/themes/one-dark.json +84 -0
- package/src/theme/themes/opencode.json +245 -0
- package/src/theme/themes/orng.json +249 -0
- package/src/theme/themes/osaka-jade.json +93 -0
- package/src/theme/themes/palenight.json +222 -0
- package/src/theme/themes/rosepine.json +234 -0
- package/src/theme/themes/solarized.json +223 -0
- package/src/theme/themes/synthwave84.json +226 -0
- package/src/theme/themes/tokyonight.json +243 -0
- package/src/theme/themes/vercel.json +245 -0
- package/src/theme/themes/vesper.json +218 -0
- package/src/theme/themes/zenburn.json +223 -0
- package/src/theme/types.ts +225 -0
- package/src/types/sql.d.ts +4 -0
- package/src/utils/ansi-parser.ts +225 -0
- package/src/utils/format.ts +46 -0
- package/src/utils/id.ts +15 -0
- package/src/utils/logger.ts +112 -0
- package/src/utils/prerequisites.ts +118 -0
- package/src/utils/shell.ts +9 -0
- package/src/wizard/flows.ts +419 -0
- package/src/wizard/index.ts +37 -0
- package/src/wizard/prompts.ts +190 -0
- package/src/workspace/detect.test.ts +51 -0
- package/src/workspace/detect.ts +223 -0
- package/src/workspace/index.ts +71 -0
- package/src/workspace/init.ts +131 -0
- package/src/workspace/paths.ts +143 -0
- package/src/workspace/project.ts +164 -0
- 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
|
+
}
|
package/src/core/tmux.ts
ADDED
|
@@ -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
|
+
}
|
package/src/git/.gitkeep
ADDED
|
File without changes
|