From dd23a52b41977e2b4120ccb638d51436ba1dc005 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 23 Aug 2026 16:45:20 -0700 Subject: [PATCH] Fix four upgrade-path defects the coherence audit found MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ~/.claude.json write was `printf ... > "$CLAUDE_JSON"`, which truncates before it writes. A write that fails part-way — a full home volume, which is the exact condition half this release exists to prevent — leaves the file unparseable, and it never self-heals: the next start's jq fails on the corrupt file, MERGED is empty, and the guard skips the write that would have repaired it. That file holds the OAuth account, so the failure mode is a permanently lost login, in service of a cosmetic flag that suppresses a tip. Demonstrated: old pattern loses the credential, new tmp+rename leaves the original intact. The correct pattern was already in triple-c-task-runner. The web terminal scoped its xterm key handler to Claude sessions but not its mobile input bar or its dedicated newline button, so both sent ESC+CR into `bash -l`, where readline has no binding for it. Silent no-op, and worse from a button that stays on screen looking live. Both now consult the active session's type, and the button is disabled with a reason on a shell tab. The Config tab claimed "Off overrides a global On" without qualification. True for the env-var-driven settings, false for TUI mode, Effort level and Focus mode, whose off state is *removing* a key — an older base image's entrypoint ignores the instruction to remove it. The copy now says so and points at the base-image update. HOW-TO-USE.md said there is no add-task form; AutomationTab renders a "New task" button. That file is fetched from GitHub at runtime by help_commands.rs, so the error was live in every user's Help dialog. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc --- HOW-TO-USE.md | 10 +++-- app/src-tauri/src/web_terminal/terminal.html | 38 ++++++++++++++++++- .../projects/home/config/RuntimeSection.tsx | 9 ++++- container/entrypoint.sh | 24 +++++++++++- 4 files changed, 74 insertions(+), 7 deletions(-) diff --git a/HOW-TO-USE.md b/HOW-TO-USE.md index d744049..1acf536 100644 --- a/HOW-TO-USE.md +++ b/HOW-TO-USE.md @@ -1216,10 +1216,14 @@ change. Remember that a headless run cannot answer a permission prompt, so in an **Bypass** a task may stop early when Claude Code asks for approval; the run log records the mode that was used. -### Creating Tasks (In the Container) +### Creating Tasks -There is no "add task" form in the app. Create tasks from a terminal in the container — either type -the commands yourself in a **Shell** session, or just ask Claude to do it. +The quickest route is the **New task** button on a project's **Automation** tab, which gives you a +form for the name, the schedule and the prompt. + +You can also create tasks from a terminal in the container — type the commands yourself in a +**Shell** session, or just ask Claude to do it. That is the better route when you want Claude to +work out the schedule or the prompt for you, and it is what the rest of this section covers. ### Create a Recurring Task diff --git a/app/src-tauri/src/web_terminal/terminal.html b/app/src-tauri/src/web_terminal/terminal.html index 98248f9..6527294 100644 --- a/app/src-tauri/src/web_terminal/terminal.html +++ b/app/src-tauri/src/web_terminal/terminal.html @@ -431,6 +431,18 @@ const mobileInput = document.getElementById('mobileInput'); const btnEnter = document.getElementById('btnEnter'); const btnNewline = document.getElementById('btnNewline'); + + // Whether the *active* session understands ESC+CR as "insert a newline". + // + // Only Claude Code does. `bash -l` has no readline binding for `\e\r`, so + // sending it there is a silent no-op — which is worse from the mobile bar + // than from a hardware key, because the bar puts a dedicated button on + // screen that appears to do nothing. The xterm key handler is already scoped + // this way; these two paths were not. + function activeSessionTakesEscCr() { + const s = activeSessionId && sessions[activeSessionId]; + return !!s && s.type === 'claude'; + } const btnTab = document.getElementById('btnTab'); const btnCtrlC = document.getElementById('btnCtrlC'); const scrollBottomBtn = document.getElementById('scrollBottomBtn'); @@ -791,6 +803,7 @@ switchToSession(remaining[remaining.length - 1]); } else { activeSessionId = null; + syncNewlineButton(); emptyState.style.display = ''; } } @@ -817,6 +830,7 @@ function switchToSession(sessionId) { activeSessionId = sessionId; + syncNewlineButton(); // Update tab styles document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); @@ -896,7 +910,7 @@ // reasoning, as the terminal's own key handler above. A hardware // keyboard on a tablet is the only way to reach this; the phone case is // the dedicated newline button beside Enter. - sendTerminalInput(e.shiftKey ? '\x1b\r' : '\r'); + sendTerminalInput(e.shiftKey && activeSessionTakesEscCr() ? '\x1b\r' : '\r'); } else if (e.key === 'Tab') { e.preventDefault(); sendTerminalInput('\t'); @@ -904,7 +918,27 @@ }); btnEnter.onclick = () => { sendTerminalInput('\r'); mobileInput.focus(); }; - btnNewline.onclick = () => { sendTerminalInput('\x1b\r'); mobileInput.focus(); }; + btnNewline.onclick = () => { + if (!activeSessionTakesEscCr()) { mobileInput.focus(); return; } + sendTerminalInput('\x1b\r'); + mobileInput.focus(); + }; + + // Keep the button's affordance honest: on a shell tab there is no byte that + // means "newline without running the line", so the control is disabled + // rather than left looking live. + function syncNewlineButton() { + const usable = activeSessionTakesEscCr(); + btnNewline.disabled = !usable; + btnNewline.title = usable + ? 'Insert a newline without submitting (Shift+Enter)' + : 'Only Claude sessions support this — a shell runs the line instead'; + } + + // With no session open yet, `activeSessionTakesEscCr()` is already false — + // but nothing had called this, so the button rendered live before the first + // tab existed. + syncNewlineButton(); btnTab.onclick = () => { sendTerminalInput('\t'); mobileInput.focus(); }; btnCtrlC.onclick = () => { sendTerminalInput('\x03'); mobileInput.focus(); }; diff --git a/app/src/components/projects/home/config/RuntimeSection.tsx b/app/src/components/projects/home/config/RuntimeSection.tsx index c60868e..9bbb19e 100644 --- a/app/src/components/projects/home/config/RuntimeSection.tsx +++ b/app/src/components/projects/home/config/RuntimeSection.tsx @@ -109,7 +109,14 @@ export default function RuntimeSection({ /dev/null; then + # Write to a temp file and rename, never `> "$CLAUDE_JSON"`. + # + # `>` truncates before it writes, so a write that fails part-way — a + # full home volume is the obvious way, and bounding that volume is + # what half this release is about — leaves the file unparseable. This + # file holds the OAuth account, and the damage does not self-heal: the + # next start's `jq` fails on the corrupt file, `MERGED` is empty, and + # the guard below skips the write that would have repaired it. So the + # failure mode is a permanently lost login, for a purely cosmetic flag + # that suppresses a "run /terminal-setup" tip. + # + # `triple-c-task-runner` already does it this way; this block was + # modelled on the awsAuthRefresh one above, which has the same flaw but + # only fires when a Bedrock SSO command is configured. MERGED=$(jq '.shiftEnterKeyBindingInstalled = true' "$CLAUDE_JSON" 2>/dev/null) if [ -n "$MERGED" ]; then - printf '%s\n' "$MERGED" > "$CLAUDE_JSON" + CLAUDE_JSON_TMP="${CLAUDE_JSON}.triple-c-tmp" + if printf '%s\n' "$MERGED" > "$CLAUDE_JSON_TMP" 2>/dev/null; then + mv -f "$CLAUDE_JSON_TMP" "$CLAUDE_JSON" 2>/dev/null || rm -f "$CLAUDE_JSON_TMP" + else + # Out of space, or the volume went read-only. The original is + # untouched, which is the whole point. + rm -f "$CLAUDE_JSON_TMP" + echo "entrypoint: warning — could not set shiftEnterKeyBindingInstalled (leaving ~/.claude.json as it was)" + fi fi fi else