@@ -90,7 +90,7 @@ import {
9090 mkdirSync , readdirSync , statSync , unlinkSync , renameSync ,
9191 openSync , closeSync ,
9292} from "fs" ;
93- import { resolve , join , basename } from "path" ;
93+ import { resolve , join , basename , sep } from "path" ;
9494import { networkInterfaces } from "os" ;
9595import { createInterface } from "readline" ;
9696import { execFileSync , execSync } from "child_process" ;
@@ -246,6 +246,13 @@ const say = {
246246 } ,
247247} ;
248248
249+ // ─── Error taxonomy ───────────────────────────────────────────────────────────
250+ // UserError marks problems caused by user input (unknown thread, taken alias,
251+ // malformed args). The top-level handler maps it to exit code 2, keeping the
252+ // documented contract: 0 success, 1 environment problem, 2 user error.
253+
254+ class UserError extends Error { }
255+
249256// ─── Thread record schema ─────────────────────────────────────────────────────
250257
251258type Thread = {
@@ -273,7 +280,12 @@ function readThread(id: number): Thread | null {
273280}
274281
275282function writeThread ( t : Thread ) : void {
276- writeFileSync ( threadPath ( t . id ) , JSON . stringify ( t , null , 2 ) + "\n" ) ;
283+ // Atomic write: write to a temp file then rename over the target so a crash
284+ // mid-write can never leave a truncated/corrupt thread record behind.
285+ const target = threadPath ( t . id ) ;
286+ const tmp = `${ target } .tmp-${ process . pid } ` ;
287+ writeFileSync ( tmp , JSON . stringify ( t , null , 2 ) + "\n" ) ;
288+ renameSync ( tmp , target ) ;
277289}
278290
279291function listThreads ( ) : Thread [ ] {
@@ -297,15 +309,15 @@ function listThreads(): Thread[] {
297309function allocateThread ( name : string | null ) : Thread {
298310 if ( name !== null ) {
299311 if ( ! ALIAS_PATTERN . test ( name ) ) {
300- throw new Error (
312+ throw new UserError (
301313 `Invalid name "${ name } ". Must start with a letter and contain only ` +
302314 `letters, digits, "_" or "-" (max 64 chars). Pure-digit names are ` +
303315 `reserved for IDs.`
304316 ) ;
305317 }
306318 for ( const existing of listThreads ( ) ) {
307319 if ( existing . name === name ) {
308- throw new Error (
320+ throw new UserError (
309321 `Thread name "${ name } " already taken by thread #${ existing . id } .`
310322 ) ;
311323 }
@@ -507,7 +519,10 @@ function getRepoName(): string {
507519function safePath ( userPath : string ) : string | null {
508520 const root = getRepoRoot ( ) ;
509521 const resolved = resolve ( root , userPath ) ;
510- if ( ! resolved . startsWith ( root ) ) return null ;
522+ // Require the repo root itself or a path under `root + sep`; a bare
523+ // startsWith(root) check would wrongly accept sibling dirs like
524+ // "/repo-evil" when root is "/repo".
525+ if ( resolved !== root && ! resolved . startsWith ( root + sep ) ) return null ;
511526 return resolved ;
512527}
513528
@@ -578,6 +593,34 @@ rejected (no auto-create on typos). Aliases must start with a letter.`
578593 ) ;
579594}
580595
596+ // ─── EOF-safe readline questions ──────────────────────────────────────────────
597+
598+ /**
599+ * Build an EOF-safe `ask` function for a readline interface. Resolves with
600+ * the user's answer, or `null` when the input reaches EOF (Ctrl-D / closed
601+ * non-TTY stdin) — readline never invokes the question callback in that case,
602+ * which would otherwise leave the promise (and the process) hanging forever.
603+ * The `pending` hand-off guarantees each promise settles exactly once even if
604+ * the close event and the question callback race. Questions must be asked
605+ * serially (await each answer before asking the next), which is how every
606+ * call site in this file uses it; concurrent questions would overwrite the
607+ * single pending resolver.
608+ */
609+ function makeAsk ( rl : ReturnType < typeof createInterface > ) : ( q : string ) => Promise < string | null > {
610+ let pending : ( ( v : string | null ) => void ) | null = null ;
611+ rl . on ( "close" , ( ) => {
612+ const p = pending ; pending = null ;
613+ if ( p ) p ( null ) ;
614+ } ) ;
615+ return ( q : string ) => new Promise ( ( res ) => {
616+ pending = res ;
617+ rl . question ( q , ( a : string ) => {
618+ const p = pending ; pending = null ;
619+ if ( p ) p ( a ?? "" ) ;
620+ } ) ;
621+ } ) ;
622+ }
623+
581624/**
582625 * Interactive launcher shown when `bun run chat` is invoked with no args.
583626 * Lists existing threads and lets the user pick by row number, press Enter
@@ -644,10 +687,12 @@ async function interactiveStart(provider: string, model: string, thinking: strin
644687 console . log ( "" ) ;
645688
646689 const rl = createInterface ( { input : process . stdin , output : process . stdout , terminal : true } ) ;
647- const ask = ( q : string ) : Promise < string > => new Promise ( ( res ) => rl . question ( q , res ) ) ;
690+ const ask = makeAsk ( rl ) ;
648691 try {
649692 while ( true ) {
650- const raw = ( await ask ( " Select> " ) ) . trim ( ) ;
693+ const answer = await ask ( " Select> " ) ;
694+ if ( answer === null ) return null ; // EOF — treat as quit.
695+ const raw = answer . trim ( ) ;
651696 if ( raw === "q" || raw === "Q" || raw === "/exit" || raw === "/quit" ) {
652697 return null ;
653698 }
@@ -974,9 +1019,14 @@ async function runTurn(
9741019 "and risk binding the wrong session to this thread."
9751020 ) ;
9761021 }
977- created . sort ( ( a , b ) =>
978- statSync ( join ( sessionsDir , b ) ) . mtimeMs - statSync ( join ( sessionsDir , a ) ) . mtimeMs
979- ) ;
1022+ created . sort ( ( a , b ) => {
1023+ // A concurrent runner may delete/rotate a session file between the
1024+ // snapshot diff and this sort; treat vanished files as oldest.
1025+ const mtime = ( f : string ) : number => {
1026+ try { return statSync ( join ( sessionsDir , f ) ) . mtimeMs ; } catch { return 0 ; }
1027+ } ;
1028+ return mtime ( b ) - mtime ( a ) ;
1029+ } ) ;
9801030 sessionPath = join ( sessionsDir , created [ 0 ] ) ;
9811031 }
9821032
@@ -1019,6 +1069,7 @@ function cmdRemove(ref: string): void {
10191069 const t = resolveThreadRef ( ref ) ;
10201070 if ( ! t ) {
10211071 say . warn ( `No thread matching "${ ref } ".` , "Use `--list` to see existing threads." ) ;
1072+ process . exitCode = 2 ; // user error per the documented exit-code contract
10221073 return ;
10231074 }
10241075 unlinkSync ( threadPath ( t . id ) ) ;
@@ -1116,7 +1167,9 @@ async function repl(initial: Thread, rt: RuntimeState): Promise<void> {
11161167 console . log ( "" ) ;
11171168
11181169 const rl = createInterface ( { input : process . stdin , output : process . stdout , terminal : true } ) ;
1119- const ask = ( q : string ) : Promise < string > => new Promise ( ( res ) => rl . question ( q , res ) ) ;
1170+ // EOF-safe question wrapper: resolves null on Ctrl-D / closed stdin so the
1171+ // REPL exits cleanly instead of hanging on an unanswerable question.
1172+ const ask = makeAsk ( rl ) ;
11201173
11211174 function prompt ( ) : string {
11221175 const branch = getGitBranch ( ) ;
@@ -1147,7 +1200,9 @@ async function repl(initial: Thread, rt: RuntimeState): Promise<void> {
11471200 try {
11481201 // eslint-disable-next-line no-constant-condition
11491202 while ( true ) {
1150- const line = ( await ask ( prompt ( ) ) ) . trim ( ) ;
1203+ const answer = await ask ( prompt ( ) ) ;
1204+ if ( answer === null ) break ; // EOF — end the session cleanly.
1205+ const line = answer . trim ( ) ;
11511206 if ( ! line ) continue ;
11521207
11531208 // ─── Exit ─────────────────────────────────────────────────────────────
@@ -1549,7 +1604,7 @@ async function repl(initial: Thread, rt: RuntimeState): Promise<void> {
15491604 // eslint-disable-next-line no-constant-condition
15501605 while ( true ) {
15511606 const more = await ask ( " ... " ) ;
1552- if ( more . trim ( ) === "" ) break ;
1607+ if ( more === null || more . trim ( ) === "" ) break ;
15531608 lines . push ( more ) ;
15541609 }
15551610 const full = lines . join ( "\n" ) . trim ( ) ;
@@ -1588,9 +1643,9 @@ type RuntimeCfg = { provider: string; model: string; thinking: string | undefine
15881643async function promptLine ( question : string ) : Promise < string > {
15891644 const rl = createInterface ( { input : process . stdin , output : process . stdout , terminal : true } ) ;
15901645 try {
1591- return await new Promise < string > ( ( res ) => {
1592- rl . question ( question , ( a : string ) => res ( a ?? "" ) ) ;
1593- } ) ;
1646+ // makeAsk resolves null on EOF (Ctrl-D / closed stdin); map that to ""
1647+ // so callers can treat it as "user backed out".
1648+ return ( await makeAsk ( rl ) ( question ) ) ?? "" ;
15941649 } catch {
15951650 return "" ;
15961651 } finally {
@@ -1813,9 +1868,13 @@ async function main(): Promise<void> {
18131868
18141869 let cfg : RuntimeCfg = resolveRuntimeConfig ( ) ;
18151870
1871+ // Pure allocation (`--new` without a prompt or thread ref) never contacts a
1872+ // model, so it must work without an API key.
1873+ const allocationOnly = args . newThread && ! args . prompt && ! args . threadRef ;
1874+
18161875 // ── Validate config BEFORE creating threads ─────────────────────────────
18171876 // (so quitting from the guide doesn't leave orphan thread #1 behind.)
1818- if ( ! isLocalProvider ( cfg . provider ) ) {
1877+ if ( ! allocationOnly && ! isLocalProvider ( cfg . provider ) ) {
18191878 const keyName = PROVIDER_KEY_MAP [ cfg . provider ] ;
18201879 if ( keyName && ! process . env [ keyName ] ) {
18211880 const updated = await guideMissingApiKey ( cfg ) ;
@@ -1859,6 +1918,7 @@ async function main(): Promise<void> {
18591918 "Use `--list` to see existing threads, or `--new` to create one. " +
18601919 "Closed-world: unknown refs are never auto-created."
18611920 ) ;
1921+ process . exitCode = 2 ; // user error per the documented exit-code contract
18621922 return ;
18631923 }
18641924 }
@@ -1894,5 +1954,14 @@ async function main(): Promise<void> {
18941954 await repl ( activeThread , rt ) ;
18951955}
18961956
1897- main ( ) ;
1898-
1957+ main ( ) . catch ( ( err : unknown ) => {
1958+ // Top-level error handler: honour the documented exit-code contract
1959+ // (1 = environment problem, 2 = user error) and print a readable message
1960+ // instead of an unhandled-rejection stack trace.
1961+ say . error (
1962+ err instanceof UserError ? "Invalid request" : "Startup failed" ,
1963+ err instanceof Error ? err . message : String ( err ) ,
1964+ ) ;
1965+ cleanup ( ) ;
1966+ process . exit ( err instanceof UserError ? 2 : 1 ) ;
1967+ } ) ;
0 commit comments