The walk-away endgame — review a diff on your phone, then land it without typing
git into a mobile terminal. MVP bounded to per-file stage/unstage, commit, and
push the current branch; discard/checkout/reset deliberately deferred (nothing here
mutates working-tree file contents).
- src/http/git-ops.ts (new): stageFiles/commit/push (execFile, no shell, never
throws). Path containment: every files[] entry realpath-contained under the repo,
argv after `--`, capped at diffMaxFiles. Commit message: empty/over-5000 → 400,
single -m argv. Push: current branch only — has-upstream → plain `git push`;
no-upstream + one remote → `git push -u <remote> <branch>`; 0 remotes → 400,
≥2 → 409, detached HEAD → 400. Remote AND branch read from the repo, never the
client. NEVER --force / +refspec. GIT_TERMINAL_PROMPT=0 + ssh BatchMode fail auth
fast (401). Errors classified to fixed safe strings (raw stderr never surfaced).
- POST /projects/git/{stage,commit,push} — all behind requireAllowedOrigin +
GIT_OPS_ENABLED kill-switch + per-IP rate limits (stage/commit 30/min, push 6/min)
+ isValidGitDir. public/diff.ts: per-file Stage/Unstage + a commit/push bar
(all text via textContent; re-loads the diff on success).
Verified: typecheck + build:web clean, git-ops tests 213 pass (unit covers escape
rejection / empty+overlong commit / push argv upstream-vs-no-upstream / classified
errors), full suite green at --test-timeout=30000.
Closes the create-only loop — delete losing worktrees and prune stale ones without
a terminal. Destructive, so guarded hard:
- src/http/worktrees.ts: removeWorktree + pruneWorktrees (execFile, no shell, never
rm -rf; git worktree remove [--force] / git worktree prune). Safeguards:
(1) target realpath must match an entry git itself reports in `git worktree list`
for THIS repo → 404 otherwise (arbitrary FS paths never match, so git is never
invoked against them); (2) reject the MAIN worktree → 400; (3) realpath
containment on request + each list entry, operating on git's canonical path (+ --);
(4) dirty tree without force → 409 "force required"; (5) locked → 409; (6) errors
classified to fixed safe strings (raw stderr/paths never leaked).
- DELETE /projects/worktree + POST /projects/worktree/prune — both behind
requireAllowedOrigin + worktreeEnabled + audit-logged, mirroring the create route.
- public/projects.ts: ✕ remove button per linked-worktree row (hidden for main/locked)
+ a prune button; force needs an explicit second confirm (no single-click data loss).
Verified: typecheck + build:web clean, worktree tests 108 pass (unit covers
reject-non-registered / reject-main / reject-escape / refuse-dirty-without-force /
clean-remove / force-remove / prune), full suite green at --test-timeout=30000.
Four small, high-delight features that turn passive capture into glanceable signals.
- Sync chip on project cards: ahead/behind vs upstream + last-commit time, folded
into the existing concurrent per-repo metadata pass (git rev-list --count
--left-right @{u}...HEAD + git log -1 --format=%ct; no upstream → undefined, no route).
- Cost budget guard: COST_BUDGET_USD env (0 = off); a per-session one-shot latch
(Session.budgetNotified, cost is monotonic so never re-armed) fires a single push
on threshold crossing in manager.handleStatusLine; the already-broadcast telemetry
frame carries the warn (tg-cost-warn styling derived from costUsd>=budget via
/config/ui — no new ServerMessage). web-push title added to sw-push.js.
- "While you were away" digest: GET /digest?since= → {finished, needsInput, stuck,
totalCostUsd, sessions[]} aggregate over manager.list(); FE banner on reconnect.
- Recent commits per project: src/http/git-log.ts (NUL-delimited git log → CommitInfo[]),
GET /projects/log?path= (isValidGitDir), textContent-inert render in project detail.
All git via execFile (no shell) + validated cwd; new routes read-only; commit
messages rendered via textContent. Verified: typecheck + build:web clean, 1904 pass
at --test-timeout=30000 (two default-5s failures are slow-sandbox real-subprocess
timeout flakes — the known ring-buffer test + a new real-git-clone sync test — not
logic regressions).
Per-project chip: PR state · N checks passing · mergeable — glance from the phone,
re-engage only when it's red, instead of dropping into a terminal for `gh pr checks`.
- src/http/gh.ts (new): single `gh pr view --json number,state,title,url,isDraft,
mergeable,headRefName,baseRefName,statusCheckRollup` (execFile, no shell, cwd =
isValidGitDir repo, timeout + maxBuffer). summarizeChecks rolls the mixed
CheckRun/StatusContext rollup into {total,passing,failing,pending}. Never throws —
degrades to not-installed (ENOENT) / unauthenticated / no-pr / error / disabled.
Cache keyed by repoPath+branch (reuses projectScanTtlMs) with in-flight dedupe.
- src/types.ts: additive PrStatus/PrAvailability/PrCheckSummary; config GH_ENABLED
(default on) + GH_TIMEOUT_MS (8s).
- GET /projects/pr?path= (read-only, isValidGitDir); public/gh-chip.ts render-only
chip mounted in the project detail header (git repos only).
Read-only, host's own authed gh (same trust as the shell). No untrusted argv (only
the validated cwd); gh stdout/token never logged; the attacker-controllable PR title
is rendered inert via textContent (SEC-H4). Verified: typecheck + build:web clean,
1816 pass (gh tests 118). The 1 red is the known real-PTY ring-buffer timeout flake.
The git-diff viewer can now diff the current branch against a base commit-ish
(e.g. main) — review an agent's whole branch from your phone before merging, not
just uncommitted changes. Completes the long-deferred FR-B1.9.
- src/http/diff.ts: getDiff() gains an optional base. Three-layer defense so an
attacker-supplied base never reaches a shell or acts as a git option:
(1) isPlausibleRev() rejects leading '-', '..'/'...' ranges, metachars, control
chars, >250 chars; (2) git rev-parse --verify --quiet --end-of-options
<base>^{commit} — only a resolved 7-64 hex sha is accepted, else empty result;
(3) git diff --no-color <sha>... -- (three-dot = the branch's changes since
divergence, PR-style). execFile, no shell, timeout + maxBuffer bound.
- src/types.ts: additive optional base on DiffResult.
- src/server.ts GET /projects/diff reads ?base (400 on !isPlausibleRev); read-only.
- public/diff.ts: a "compare base" <select> (Working tree + one option per base),
disables Working/Staged tabs in base mode. public/projects.ts derives bases from
the worktree branches ∪ current branch.
Verified: typecheck + build:web clean, 1763 pass (diff tests 87 green). The 1 red
in the plain full run is the pre-existing real-PTY "ring buffer" flake (times out
at default 5s under sandbox load; 27/27 at --test-timeout=30000) — unrelated.
Remote one-tap approval was blind (you'd tap Approve without seeing Claude wants
to run `rm -rf` or rewrite a config). The pending tool's actual command / diff now
renders above the approval bar, on every attached device, riding the same broadcast
+ late-joiner rails as the existing `gate` field.
- src/http/approval-preview.ts (new, pure, never-throws): deriveApprovalPreview —
Bash → command; Edit/Write/MultiEdit/NotebookEdit → a synthetic DiffFile; else null.
Every line sanitized via sanitizeField (strips control/ANSI); caps 40 lines /
200 chars/line / 4KB (security limits, UTF-8-safe byte clamp).
- src/types.ts: additive optional `preview?: ApprovalPreview` on the status
ServerMessage + handleHookEvent (older clients ignore it).
- src/server.ts /hook/permission: derive preview from tool_input, store on the
PendingApproval entry, re-send to late joiners exactly like `gate`.
- manager.ts threads it; public/tabs.ts renderApprovalPreview (command → <pre>
textContent; diff → reused innerHTML-free renderDiffFile). Unknown tools /
plan gates fall back to today's name-only bar.
Attacker-influenced tool input → rendered via textContent/diff-renderer only,
never innerHTML. Verified independently: typecheck + build:web clean, 1692 pass.