Skip to content

Commit eaa3595

Browse files
committed
fix terminal commands hanging
1 parent 63c8471 commit eaa3595

File tree

2 files changed

+208
-98
lines changed

2 files changed

+208
-98
lines changed

codebuff.json

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,19 +15,46 @@
1515
"fileChangeHooks": [
1616
{
1717
"name": "backend-unit-tests",
18-
"command": "set -o pipefail && bun test $(find src -name *.test.ts ! -name *.integration.test.ts) 2>&1 | grep -Ev $'\\x1b\\[[0-9;]*m' | grep -Ev '^s*[0-9]+s+(pass|skip)|(pass)|(skip)' && bun run typecheck-only",
18+
"command": "set -o pipefail && bun test $(find src -name *.test.ts ! -name *.integration.test.ts) 2>&1 | grep -Ev '^\\(pass\\)|\\(skip\\)'",
1919
"cwd": "backend",
2020
"filePattern": "backend/**/*.ts"
2121
},
22+
{
23+
"name": "backend-typecheck",
24+
"command": "bun run typecheck-only",
25+
"cwd": "backend",
26+
"filePattern": "backend/**/*.ts"
27+
},
28+
2229
{
2330
"name": "npm-app-unit-tests",
24-
"command": "set -o pipefail && bun test $(find src -name *.test.ts ! -name *.integration.test.ts) 2>&1 | grep -Ev $'\\x1b\\[[0-9;]*m' | grep -Ev '^s*[0-9]+s+(pass|skip)|(pass)|(skip)' && bun run typecheck-only",
31+
"command": "set -o pipefail && bun test $(find src -name *.test.ts ! -name *.integration.test.ts) 2>&1 | grep -Ev '^\\(pass\\)|\\(skip\\)'",
32+
"cwd": "npm-app",
33+
"filePattern": "npm-app/**/*.ts"
34+
},
35+
{
36+
"name": "npm-typecheck",
37+
"command": "bun run typecheck-only",
2538
"cwd": "npm-app",
2639
"filePattern": "npm-app/**/*.ts"
2740
},
41+
42+
{
43+
"name": "web-typecheck",
44+
"command": "bun run typecheck-only",
45+
"cwd": "web",
46+
"filePattern": "web/**/*.ts"
47+
},
48+
2849
{
2950
"name": "common-unit-tests",
30-
"command": "set -o pipefail && bun test $(find src -name *.test.ts ! -name *.integration.test.ts) 2>&1 | grep -Ev $'\\x1b\\[[0-9;]*m' | grep -Ev '^s*[0-9]+s+(pass|skip)|(pass)|(skip)' && bun run typecheck-only",
51+
"command": "set -o pipefail && bun test $(find src -name *.test.ts ! -name *.integration.test.ts) 2>&1 | grep -Ev '^\\(pass\\)|\\(skip\\)'",
52+
"cwd": "common",
53+
"filePattern": "common/**/*.ts"
54+
},
55+
{
56+
"name": "common-typecheck",
57+
"command": "bun run typecheck-only",
3158
"cwd": "common",
3259
"filePattern": "common/**/*.ts"
3360
}

npm-app/src/terminal/base.ts

Lines changed: 178 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ try {
3434

3535
const COMMAND_OUTPUT_LIMIT = 10_000
3636
const promptIdentifier = '@36261@'
37+
const cwdIdentifier = '@76593@'
3738

3839
type PersistentProcess =
3940
| {
@@ -339,8 +340,122 @@ export const runTerminalCommand = async (
339340

340341
const echoLinePattern = new RegExp(`${promptIdentifier}[^\n]*\n`, 'g')
341342
const commandDonePattern = new RegExp(
342-
`^${promptIdentifier}(.*)${promptIdentifier}[\\s\\S]*${promptIdentifier}`
343+
`^${cwdIdentifier}(.*)${cwdIdentifier}[\\s\\S]*${promptIdentifier}`
343344
)
345+
/**
346+
* Executes a single command in a PTY process and returns the result when complete.
347+
*
348+
* This function handles the low-level details of running a command in a pseudo-terminal,
349+
* including parsing the output to separate command echoes from actual output, detecting
350+
* command completion, and extracting the exit code and final working directory.
351+
*
352+
* @param ptyProcess - The IPty instance to execute the command in
353+
* @param command - The shell command to execute
354+
* @param cwd - The working directory to change to before executing the command (relative to project root)
355+
* @param onChunk - Callback function called for each chunk of output as it's received
356+
*
357+
* @returns Promise that resolves with:
358+
* - `commandOutput`: The complete output from the command (excluding echo lines)
359+
* - `finalCwd`: The working directory after command execution
360+
* - `exitCode`: The command's exit code (0 for success, non-zero for failure, null if os is Windows)
361+
*
362+
* @example
363+
* ```typescript
364+
* const result = await runSinglePtyCommand(
365+
* ptyProcess,
366+
* 'ls -la',
367+
* '.',
368+
* (chunk) => console.log('Output chunk:', chunk)
369+
* );
370+
* console.log('Exit code:', result.exitCode);
371+
* console.log('Final directory:', result.finalCwd);
372+
* ```
373+
*
374+
* @internal This is a low-level utility function used by other terminal command runners.
375+
* It handles platform-specific differences between Windows and Unix-like systems.
376+
*
377+
* The function works by:
378+
* 1. Setting up a data listener on the PTY process
379+
* 2. Filtering out command echo lines (the command being typed)
380+
* 3. Detecting command completion markers (cwdIdentifier and promptIdentifier)
381+
* 4. Parsing exit codes from the shell's status messages
382+
* 5. Extracting the final working directory from the output
383+
*/
384+
function runSinglePtyCommand(
385+
ptyProcess: IPty,
386+
command: string,
387+
cwd: string,
388+
onChunk: (data: string) => void
389+
): Promise<{
390+
commandOutput: string
391+
finalCwd: string
392+
exitCode: number | null
393+
}> {
394+
const isWindows = os.platform() === 'win32'
395+
let commandOutput = ''
396+
let buffer = promptIdentifier
397+
let echoLinesRemaining = isWindows ? 1 : command.split('\n').length
398+
399+
const resultPromise = new Promise<{
400+
commandOutput: string
401+
finalCwd: string
402+
exitCode: number | null
403+
}>((resolve) => {
404+
const dataDisposable = ptyProcess.onData((data: string) => {
405+
buffer += data
406+
const suffix = suffixPrefixOverlap(buffer, promptIdentifier)
407+
let toProcess = buffer.slice(0, buffer.length - suffix.length)
408+
buffer = suffix
409+
410+
const matches = toProcess.match(echoLinePattern)
411+
if (matches) {
412+
for (let i = 0; i < matches.length && echoLinesRemaining > 0; i++) {
413+
echoLinesRemaining = Math.max(echoLinesRemaining - 1, 0)
414+
// Process normal output line
415+
toProcess = toProcess.replace(echoLinePattern, '')
416+
}
417+
}
418+
419+
const indexOfPromptIdentifier = toProcess.indexOf(cwdIdentifier)
420+
if (indexOfPromptIdentifier !== -1) {
421+
buffer = toProcess.slice(indexOfPromptIdentifier) + buffer
422+
toProcess = toProcess.slice(0, indexOfPromptIdentifier)
423+
}
424+
425+
onChunk(toProcess)
426+
commandOutput += toProcess
427+
428+
const commandDone = buffer.match(commandDonePattern)
429+
if (commandDone && echoLinesRemaining === 0) {
430+
// Command is done
431+
dataDisposable.dispose()
432+
433+
const exitCode = buffer.includes('Command completed')
434+
? 0
435+
: (() => {
436+
const match = buffer.match(
437+
/Command failed with exit code (\d+)\./
438+
)
439+
return match ? parseInt(match[1]) : null
440+
})()
441+
442+
const newWorkingDirectory = commandDone[1]
443+
444+
resolve({ commandOutput, finalCwd: newWorkingDirectory, exitCode })
445+
}
446+
})
447+
})
448+
449+
// Write the command
450+
const cdCommand = `cd ${path.resolve(getProjectRoot(), cwd)}`
451+
const commandWithCheck = isWindows
452+
? `${cdCommand} & ${command} & echo ${cwdIdentifier}%cd%${cwdIdentifier}`
453+
: `${cdCommand}; ${command}; ec=$?; printf "${cwdIdentifier}$(pwd)${cwdIdentifier}"; if [ $ec -eq 0 ]; then printf "Command completed."; else printf "Command failed with exit code $ec."; fi`
454+
ptyProcess.write(`${commandWithCheck}\r\n`)
455+
456+
return resultPromise
457+
}
458+
344459
export const runCommandPty = (
345460
persistentProcess: PersistentProcess & {
346461
type: 'pty'
@@ -404,114 +519,82 @@ export const runCommandPty = (
404519

405520
persistentProcess.timerId = timer
406521

407-
const dataDisposable = ptyProcess.onData((data: string) => {
408-
buffer += data
409-
const suffix = suffixPrefixOverlap(buffer, promptIdentifier)
410-
let toProcess = buffer.slice(0, buffer.length - suffix.length)
411-
buffer = suffix
412-
413-
const matches = toProcess.match(echoLinePattern)
414-
if (matches) {
415-
for (let i = 0; i < matches.length && echoLinesRemaining > 0; i++) {
416-
echoLinesRemaining = Math.max(echoLinesRemaining - 1, 0)
417-
// Process normal output line
418-
toProcess = toProcess.replace(echoLinePattern, '')
419-
}
420-
}
522+
runSinglePtyCommand(ptyProcess, command, cwd, (data: string) => {
523+
commandOutput += data
524+
process.stdout.write(data)
525+
}).then(async ({ finalCwd: newWorkingDirectory, exitCode }) => {
526+
const statusMessage =
527+
exitCode === null
528+
? ''
529+
: exitCode === 0
530+
? 'Complete'
531+
: `Failed with exit code: ${exitCode}`
421532

422-
const indexOfPromptIdentifier = toProcess.indexOf(promptIdentifier)
423-
if (indexOfPromptIdentifier !== -1) {
424-
buffer = toProcess.slice(indexOfPromptIdentifier) + buffer
425-
toProcess = toProcess.slice(0, indexOfPromptIdentifier)
533+
if (timer) {
534+
clearTimeout(timer)
426535
}
427-
428-
process.stdout.write(toProcess)
429-
commandOutput += toProcess
430-
431-
const commandDone = buffer.match(commandDonePattern)
432-
if (commandDone && echoLinesRemaining === 0) {
433-
// Command is done
434-
if (timer) {
435-
clearTimeout(timer)
436-
}
437-
dataDisposable.dispose()
438-
439-
const exitCode = buffer.includes('Command completed')
440-
? 0
441-
: (() => {
442-
const match = buffer.match(/Command failed with exit code (\d+)\./)
443-
return match ? parseInt(match[1]) : null
444-
})()
445-
const statusMessage = buffer.includes('Command completed')
446-
? 'Complete'
447-
: `Failed with exit code: ${exitCode}`
448-
449-
const newWorkingDirectory = commandDone[1]
450-
if (mode === 'assistant') {
451-
ptyProcess.write(`cd ${getWorkingDirectory()}\r\n`)
452-
453-
resolve({
454-
result: formatResult(
455-
command,
456-
commandOutput,
457-
`cwd: ${path.resolve(projectRoot, cwd)}\n\n${statusMessage}`
458-
),
459-
stdout: commandOutput,
460-
exitCode,
461-
})
462-
return
463-
}
464-
465-
let outsideProject = false
466-
const currentWorkingDirectory = getWorkingDirectory()
467-
let finalCwd = currentWorkingDirectory
468-
if (newWorkingDirectory !== currentWorkingDirectory) {
469-
trackEvent(AnalyticsEvent.CHANGE_DIRECTORY, {
470-
from: currentWorkingDirectory,
471-
to: newWorkingDirectory,
472-
isSubdir: isSubdir(currentWorkingDirectory, newWorkingDirectory),
473-
})
474-
if (path.relative(projectRoot, newWorkingDirectory).startsWith('..')) {
475-
outsideProject = true
476-
console.log(`
477-
Unable to cd outside of the project root (${projectRoot})
478-
479-
If you want to change the project root:
480-
1. Exit Codebuff (type "exit")
481-
2. Navigate into the target directory (type "cd ${newWorkingDirectory}")
482-
3. Restart Codebuff`)
483-
ptyProcess.write(`cd ${currentWorkingDirectory}\r\n`)
484-
} else {
485-
setWorkingDirectory(newWorkingDirectory)
486-
finalCwd = newWorkingDirectory
487-
}
488-
}
489-
536+
if (mode === 'assistant') {
490537
resolve({
491538
result: formatResult(
492539
command,
493540
commandOutput,
494541
buildArray([
495-
`cwd: ${currentWorkingDirectory}`,
496-
`${statusMessage}\n`,
497-
outsideProject &&
498-
`Detected final cwd outside project root. Reset cwd to ${currentWorkingDirectory}`,
499-
`Final **user** cwd: ${finalCwd} (Assistant's cwd is still project root)`,
500-
]).join('\n')
542+
`cwd: ${path.resolve(projectRoot, cwd)}`,
543+
statusMessage,
544+
]).join('\n\n')
501545
),
502546
stdout: commandOutput,
503547
exitCode,
504548
})
505-
return
506549
}
550+
let outsideProject = false
551+
const currentWorkingDirectory = getWorkingDirectory()
552+
let finalCwd = currentWorkingDirectory
553+
if (newWorkingDirectory !== currentWorkingDirectory) {
554+
trackEvent(AnalyticsEvent.CHANGE_DIRECTORY, {
555+
from: currentWorkingDirectory,
556+
to: newWorkingDirectory,
557+
isSubdir: isSubdir(currentWorkingDirectory, newWorkingDirectory),
558+
})
559+
if (path.relative(projectRoot, newWorkingDirectory).startsWith('..')) {
560+
outsideProject = true
561+
console.log(`
562+
Unable to cd outside of the project root (${projectRoot})
563+
564+
If you want to change the project root:
565+
1. Exit Codebuff (type "exit")
566+
2. Navigate into the target directory (type "cd ${newWorkingDirectory}")
567+
3. Restart Codebuff`)
568+
await runSinglePtyCommand(
569+
ptyProcess,
570+
`cd ${currentWorkingDirectory}`,
571+
'.',
572+
() => {}
573+
)
574+
} else {
575+
setWorkingDirectory(newWorkingDirectory)
576+
finalCwd = newWorkingDirectory
577+
}
578+
}
579+
580+
resolve({
581+
result: formatResult(
582+
command,
583+
commandOutput,
584+
buildArray([
585+
`cwd: ${currentWorkingDirectory}`,
586+
`${statusMessage}\n`,
587+
outsideProject &&
588+
`Detected final cwd outside project root. Reset cwd to ${currentWorkingDirectory}`,
589+
`Final **user** cwd: ${finalCwd} (Assistant's cwd is still project root)`,
590+
]).join('\n')
591+
),
592+
stdout: commandOutput,
593+
exitCode,
594+
})
507595
})
508596

509-
// Write the command
510-
const cdCommand = `cd ${path.resolve(projectRoot, cwd)}`
511-
const commandWithCheck = isWindows
512-
? `${cdCommand} & ${command} & echo ${promptIdentifier}%cd%${promptIdentifier}`
513-
: `${cdCommand}; ${command}; ec=$?; printf "${promptIdentifier}$(pwd)${promptIdentifier}"; if [ $ec -eq 0 ]; then printf "Command completed."; else printf "Command failed with exit code $ec."; fi`
514-
ptyProcess.write(`${commandWithCheck}\r`)
597+
return
515598
}
516599

517600
const runCommandChildProcess = (

0 commit comments

Comments
 (0)