From 4441d2abd9f0e4b45750c18f3d31b993a104501e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 7 Nov 2026 17:51:40 +0000 Subject: [PATCH 1/7] test(server): reproduce #32556 fish setup parse error never reports completion Interactive fish runs each carriage-return-submitted line on its own and discards one that fails to parse, so a setup command typed on a line separate from the completion printf loses the sentinel and setup hangs. The test asserts the command and the completion report are submitted on the same line for fish. Co-Authored-By: Claude Opus 5.5 --- .../src/project/ProjectSetupScriptRunner.test.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index 4eb466200bc90..1e154d5330460 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -41,6 +41,7 @@ const makeHarness = Effect.fn(function* (options: { readonly history?: string; readonly opening?: Deferred.Deferred; readonly shell?: string | null; + readonly command?: string; }) { const listeners = new Set<(event: TerminalEvent) => Effect.Effect>(); const writes = yield* Queue.unbounded(); @@ -88,7 +89,7 @@ const makeHarness = Effect.fn(function* (options: { { id: "setup", name: "Setup", - command: "vp i", + command: options.command ?? "vp i", icon: "configure", runOnWorktreeCreate: true, }, @@ -104,6 +105,7 @@ const setup = Effect.fn(function* ( options: { readonly history?: string; readonly shell?: string | null; + readonly command?: string; } = {}, ) { const harness = yield* makeHarness(options); @@ -123,6 +125,17 @@ describe("ProjectSetupScriptRunner", () => { }), ); + it.effect("a fish setup command that fails to parse still reports completion", () => + Effect.gen(function* () { + const command = 'node "${HOME:-.}/x.mjs" setup'; + const { writes } = yield* setup({ shell: "/usr/bin/fish", command }); + yield* TestClock.adjust("3 seconds"); + // Interactive fish runs each submitted line alone and discards one that fails to parse. + const submitted = (yield* Queue.take(writes)).split("\r"); + expect(submitted.find((line) => line.includes(command))).toMatch(/printf .*\$status/); + }), + ); + it.effect("shows a startup prompt and types only once the line editor starts", () => Effect.gen(function* () { const { result, writes, outputLines, emit } = yield* setup(); From beb4765c37d2764651e0034844b897f9d4c02d44 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 7 Nov 2026 17:56:53 +0000 Subject: [PATCH 2/7] fix(server): fish setup commands that fail to parse still report completion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Interactive fish runs each carriage-return-submitted line on its own and discards one that fails to parse. The fish wrapper typed `begin`, the command and `end; printf …` as separate submissions, so a command fish rejects (such as `${HOME:-.}`) dropped the block and the completion sentinel never printed, leaving the thread on "Preparing workspace". Submit the command and the completion report as one line instead: `eval ''; printf …`, with `\` and `'` escaped for fish single quotes. Parse errors now surface with status 121 and setup reports failure. Fixes pingdotgg/t3code#32556 Co-Authored-By: Claude Opus 5.5 --- apps/server/src/project/ProjectSetupScriptRunner.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/server/src/project/ProjectSetupScriptRunner.ts b/apps/server/src/project/ProjectSetupScriptRunner.ts index 8e7c3105b36bc..3e21873174ce4 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.ts @@ -177,6 +177,7 @@ function resolveCompletionShell(shell: string): CompletionShell | null { } // A block with its own closing line protects the sentinel from trailing comments, heredocs and scripts reading stdin. +// Interactive fish runs each submitted line alone and drops one that fails to parse, so it gets a single eval line. function wrapCommandForCompletion( command: string, shell: CompletionShell, @@ -187,7 +188,7 @@ function wrapCommandForCompletion( case "powershell": return `$global:LASTEXITCODE = $null; & {\r${body}\r}; if ($null -ne $LASTEXITCODE) { $__t3c = $LASTEXITCODE } elseif ($?) { $__t3c = 0 } else { $__t3c = 1 }; Write-Host "${sentinel}$__t3c"`; case "fish": - return `begin\r${body}\rend; printf '\\n${sentinel}%s\\n' $status`; + return `eval '${body.replace(/[\\']/g, "\\$&")}'; printf '\\n${sentinel}%s\\n' $status`; case "posix": return `( ${body}\r); printf '\\n${sentinel}%s\\n' "$?"`; } From fd97d361a41fc6b576b1323d0132afa7da38239a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 7 Nov 2026 18:16:40 +0000 Subject: [PATCH 3/7] docs(server): note that fish setup commands skip abbreviation expansion Co-Authored-By: Claude Opus 5.5 --- apps/server/src/project/ProjectSetupScriptRunner.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/server/src/project/ProjectSetupScriptRunner.ts b/apps/server/src/project/ProjectSetupScriptRunner.ts index 3e21873174ce4..1a7512fa81820 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.ts @@ -177,7 +177,8 @@ function resolveCompletionShell(shell: string): CompletionShell | null { } // A block with its own closing line protects the sentinel from trailing comments, heredocs and scripts reading stdin. -// Interactive fish runs each submitted line alone and drops one that fails to parse, so it gets a single eval line. +// Interactive fish runs each submitted line alone and drops one that fails to parse, so it gets a single eval line +// instead (abbreviations, which only the line editor expands, do not apply inside it). function wrapCommandForCompletion( command: string, shell: CompletionShell, From f2102e2808a77633a5fecf4b46b83078135f852b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 7 Nov 2026 18:40:43 +0000 Subject: [PATCH 4/7] fix(server): fish setup commands that fail to parse run nothing and report failure `eval` treats a parse error as the failure of that one statement, so a body whose first line fails to parse skipped that line, ran the rest and reported success. Source the body through `psub` instead: fish parses the whole body before running any of it, so a parse error reports status 1 and runs nothing. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/project/ProjectSetupScriptRunner.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/server/src/project/ProjectSetupScriptRunner.ts b/apps/server/src/project/ProjectSetupScriptRunner.ts index 1a7512fa81820..e601d0e420184 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.ts @@ -177,8 +177,9 @@ function resolveCompletionShell(shell: string): CompletionShell | null { } // A block with its own closing line protects the sentinel from trailing comments, heredocs and scripts reading stdin. -// Interactive fish runs each submitted line alone and drops one that fails to parse, so it gets a single eval line -// instead (abbreviations, which only the line editor expands, do not apply inside it). +// Interactive fish runs each submitted line alone and drops one that fails to parse, so it gets a single line that +// `source`s the body: fish parses all of it before running any (`eval` would skip only the failing statement). +// Abbreviations, which only the line editor expands, do not apply inside it. function wrapCommandForCompletion( command: string, shell: CompletionShell, @@ -189,7 +190,7 @@ function wrapCommandForCompletion( case "powershell": return `$global:LASTEXITCODE = $null; & {\r${body}\r}; if ($null -ne $LASTEXITCODE) { $__t3c = $LASTEXITCODE } elseif ($?) { $__t3c = 0 } else { $__t3c = 1 }; Write-Host "${sentinel}$__t3c"`; case "fish": - return `eval '${body.replace(/[\\']/g, "\\$&")}'; printf '\\n${sentinel}%s\\n' $status`; + return `source (printf '%s' '${body.replace(/[\\']/g, "\\$&")}' | psub); printf '\\n${sentinel}%s\\n' $status`; case "posix": return `( ${body}\r); printf '\\n${sentinel}%s\\n' "$?"`; } From a3c23f2c4a42bb20f453281666d6e132c6041b9a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 20:15:28 +0000 Subject: [PATCH 5/7] test(server): drop comment that repeats the fish wrapper note AI-assisted; verified with the tests and tools listed above. --- apps/server/src/project/ProjectSetupScriptRunner.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index 1e154d5330460..ff15b8843b7c3 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -130,7 +130,6 @@ describe("ProjectSetupScriptRunner", () => { const command = 'node "${HOME:-.}/x.mjs" setup'; const { writes } = yield* setup({ shell: "/usr/bin/fish", command }); yield* TestClock.adjust("3 seconds"); - // Interactive fish runs each submitted line alone and discards one that fails to parse. const submitted = (yield* Queue.take(writes)).split("\r"); expect(submitted.find((line) => line.includes(command))).toMatch(/printf .*\$status/); }), From fbba86f06da57915b7b12d4d0c4ddc53fbb26c2c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 20:30:13 +0000 Subject: [PATCH 6/7] fix(server): keep fish abbreviations in setup commands by parse-checking first Sourcing the body hid it from the line editor, so abbreviations stopped expanding. Parse the quoted body with fish --no-execute first and report its status when that fails, then type the body in the begin/end block as before. AI-assisted; verified with the tests and tools listed above. --- .../src/project/ProjectSetupScriptRunner.test.ts | 8 ++++++-- apps/server/src/project/ProjectSetupScriptRunner.ts | 13 +++++++++---- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index ff15b8843b7c3..eb94f348e7a50 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -130,8 +130,12 @@ describe("ProjectSetupScriptRunner", () => { const command = 'node "${HOME:-.}/x.mjs" setup'; const { writes } = yield* setup({ shell: "/usr/bin/fish", command }); yield* TestClock.adjust("3 seconds"); - const submitted = (yield* Queue.take(writes)).split("\r"); - expect(submitted.find((line) => line.includes(command))).toMatch(/printf .*\$status/); + const [check, ...rest] = (yield* Queue.take(writes)).split("\r"); + expect(check).toContain( + `'node "\${HOME:-.}/x.mjs" setup' | '/usr/bin/fish' --no-config --no-execute; or printf`, + ); + // Typed on its own line so the fish line editor still expands abbreviations. + expect(rest).toContain(command); }), ); diff --git a/apps/server/src/project/ProjectSetupScriptRunner.ts b/apps/server/src/project/ProjectSetupScriptRunner.ts index e601d0e420184..ed70791531e6c 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.ts @@ -176,13 +176,17 @@ function resolveCompletionShell(shell: string): CompletionShell | null { return "posix"; } +function quoteFish(value: string): string { + return `'${value.replace(/[\\']/g, "\\$&")}'`; +} + // A block with its own closing line protects the sentinel from trailing comments, heredocs and scripts reading stdin. -// Interactive fish runs each submitted line alone and drops one that fails to parse, so it gets a single line that -// `source`s the body: fish parses all of it before running any (`eval` would skip only the failing statement). -// Abbreviations, which only the line editor expands, do not apply inside it. +// Interactive fish keeps a line that fails to parse in its editor, running nothing, so the body is first parsed as a +// quoted string. It stays typed rather than sourced because only the line editor expands abbreviations. function wrapCommandForCompletion( command: string, shell: CompletionShell, + shellPath: string, sentinel: string, ): string { const body = command.replace(/\r?\n/g, "\r"); @@ -190,7 +194,7 @@ function wrapCommandForCompletion( case "powershell": return `$global:LASTEXITCODE = $null; & {\r${body}\r}; if ($null -ne $LASTEXITCODE) { $__t3c = $LASTEXITCODE } elseif ($?) { $__t3c = 0 } else { $__t3c = 1 }; Write-Host "${sentinel}$__t3c"`; case "fish": - return `source (printf '%s' '${body.replace(/[\\']/g, "\\$&")}' | psub); printf '\\n${sentinel}%s\\n' $status`; + return `printf '%s' ${quoteFish(body)} | ${quoteFish(shellPath)} --no-config --no-execute; or printf '\\n${sentinel}%s\\n' $status\rbegin\r${body}\rend; printf '\\n${sentinel}%s\\n' $status`; case "posix": return `( ${body}\r); printf '\\n${sentinel}%s\\n' "$?"`; } @@ -503,6 +507,7 @@ export const make = Effect.gen(function* () { commandLine = wrapCommandForCompletion( script.command, completionShell, + shell, completionSentinel(completionToken), ); } From d805edb045562eba4bb19929f15d31d3aacbc61f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 20:44:07 +0000 Subject: [PATCH 7/7] fix(server): skip the fish parse check where it could disagree with the editor The --no-execute check sees abbreviations unexpanded and needs fish 3.3 for --no-config, so it could report failure for commands that then ran fine. Run it only on fish 3.3+ when the body uses no abbreviation; otherwise keep main's wrapper. AI-assisted; verified with the tests and tools listed above. --- apps/server/src/project/ProjectSetupScriptRunner.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/apps/server/src/project/ProjectSetupScriptRunner.ts b/apps/server/src/project/ProjectSetupScriptRunner.ts index ed70791531e6c..bc6f978938f14 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.ts @@ -181,8 +181,8 @@ function quoteFish(value: string): string { } // A block with its own closing line protects the sentinel from trailing comments, heredocs and scripts reading stdin. -// Interactive fish keeps a line that fails to parse in its editor, running nothing, so the body is first parsed as a -// quoted string. It stays typed rather than sourced because only the line editor expands abbreviations. +// Interactive fish leaves a body that fails to parse in its editor, so it is parse-checked first, except on fish +// before 3.3 (no --no-config) or when it uses an abbreviation, which only the editor expands. function wrapCommandForCompletion( command: string, shell: CompletionShell, @@ -194,7 +194,14 @@ function wrapCommandForCompletion( case "powershell": return `$global:LASTEXITCODE = $null; & {\r${body}\r}; if ($null -ne $LASTEXITCODE) { $__t3c = $LASTEXITCODE } elseif ($?) { $__t3c = 0 } else { $__t3c = 1 }; Write-Host "${sentinel}$__t3c"`; case "fish": - return `printf '%s' ${quoteFish(body)} | ${quoteFish(shellPath)} --no-config --no-execute; or printf '\\n${sentinel}%s\\n' $status\rbegin\r${body}\rend; printf '\\n${sentinel}%s\\n' $status`; + return [ + "begin; if string match -qr '^(3\\.([3-9]|[1-9][0-9])|[4-9]|[1-9][0-9])\\.' -- $version", + "and not abbr --show | string match -q -- '* --regex *'", + "set -l a (abbr --list | string escape --style=regex)", + `if test (count $a) -eq 0; or not string match -qr -- '(^|[\\s;|&(])('(string join '|' -- $a)')($|[\\s;|&)])' ${quoteFish(body)}`, + `printf '%s' ${quoteFish(body)} | ${quoteFish(shellPath)} --no-config --no-execute; or printf '\\n${sentinel}%s\\n' $status`, + `end; end; end\rbegin\r${body}\rend; printf '\\n${sentinel}%s\\n' $status`, + ].join("; "); case "posix": return `( ${body}\r); printf '\\n${sentinel}%s\\n' "$?"`; }