17 KiB
Quick wins: sync chip + cost guard + reconnect digest + recent commits
Four small, independent features that ride existing seams. None touches the byte-shuttle WS stream; all new HTTP routes are side-channel and follow the established /projects/* / /hook/* patterns. Read-only routes get no Origin guard (same threat model as /projects — src/server.ts:281); the cost guard rides the already-injected NotifyService DI seam and the stuckNotified one-shot-latch pattern (src/session/manager.ts:271-287).
Shared coordination edit up front: src/types.ts gains ProjectInfo.ahead/behind/lastCommitMs (§a), NotifyClass 'budget' + Session.budgetNotified + Config.costBudgetUsd + UiConfig.costBudgetUsd (§b), DigestResult/DigestSession (§c), CommitLogEntry/GitLogResult (§d). Do all type edits in one commit so the four sub-features can then proceed in parallel.
Contract
(a) Sync chip — no new route
Folded into the existing GET /projects payload. ProjectInfo gains three optional fields, populated in the cached per-repo metadata pass:
// src/types.ts — extend ProjectInfo (currently line 279-287)
export interface ProjectInfo {
name: string
path: string
isGit: boolean
branch?: string
dirty?: boolean
lastActiveMs?: number
ahead?: number // commits on HEAD not on @{u} (git rev-list, right count)
behind?: number // commits on @{u} not on HEAD (git rev-list, left count)
lastCommitMs?: number // git log -1 --format=%ct * 1000 (HEAD commit time)
sessions: ProjectSessionRef[]
}
@{u} with no upstream / non-git → all three undefined (best-effort, never throws). No new env var: gated by the existing projectDirtyCheck (which already means "spend git subprocess time per repo").
(b) Cost budget guard + push alert
- New env
COST_BUDGET_USD(dollars, float ≥ 0; default0= disabled) →Config.costBudgetUsd: number(src/types.tsConfig, alongside B2statuslineTtlMsat line 65). - New
NotifyClassmember'budget'(src/types.ts:376):'needs-input' | 'done' | 'stuck' | 'budget'. - New
SessionfieldbudgetNotified: boolean(src/types.tsSession, next tostuckNotifiedat line 227-228) — one-shot latch, never re-armed (cost is monotonic). GET /config/uipayload gainscostBudgetUsd?: numberso the FE can derive warn-styling client-side:export interface UiConfig { allowAutoMode: boolean; costBudgetUsd?: number }- No new ServerMessage variant. The "warning broadcast" is the existing
{type:'telemetry', telemetry}frame (already broadcast on every statusLine,manager.ts:260); the warn is derived on the client (costUsd >= costBudgetUsd). The distinct new server action on threshold crossing is a singlenotifyService.notify(session, 'budget')push. (Rationale: keeps the frozenServerMessagecontract intypes.ts:109-120untouched — KISS/YAGNI. A dedicated frame was considered and rejected: cost overage is not aClaudeStatus.) renderTelemetryGauge(public/preview-grid.ts:197) gains a 4th paramcostBudgetUsd?: number; the cost chip (line 224-227) gets classtg-cost-warnwhentelemetry.costUsd >= budget, mirroring the ctx>80% path at line 219.
(c) While-you-were-away reconnect digest
New read-only route GET /digest?since=<epochMs> (no Origin guard; same as /live-sessions). Aggregates manager.list():
export interface DigestSession {
id: string
title?: string // last cwd segment
status: ClaudeStatus
costUsd?: number // telemetry.costUsd
lastOutputAt?: number
finished: boolean // status==='idle' && lastOutputAt > since
needsInput: boolean // status==='waiting'
stuck: boolean // status==='stuck'
}
export interface DigestResult {
since: number
generatedAt: number
total: number
finished: number
needsInput: number
stuck: number
working: number
totalCostUsd: number
sessions: DigestSession[]
}
Response: 200 + DigestResult (empty aggregate when no sessions). since clamped to a finite non-negative number (bad/absent → 0, i.e. "everything is new").
(d) Recent-commits log per project
New read-only route GET /projects/log?path=<abs>&n=<int> (no Origin guard; guarded by isValidGitDir, src/server.ts:123, exactly like /projects/diff at line 661-678).
export interface CommitLogEntry { hash: string; at: number; subject: string } // at = %ct*1000
export interface GitLogResult { commits: CommitLogEntry[]; truncated: boolean }
pathmissing/empty →400; not a valid git dir →404(three-prongisValidGitDir); git failure →500.nparsed to int, clamped to[1, GIT_LOG_MAX](const50; default20).- Git command (NUL-record, US-field delimited so subjects with tabs/newlines can't corrupt parsing):
git log --no-color -z -n <n> --format=%h%x1f%ct%x1f%s (cwd: repoPath, timeout, maxBuffer)
Files to change
| Path | Change |
|---|---|
src/types.ts |
Coordination edit. Add ProjectInfo.ahead/behind/lastCommitMs; NotifyClass 'budget'; Session.budgetNotified; Config.costBudgetUsd; UiConfig.costBudgetUsd; new DigestResult/DigestSession, CommitLogEntry/GitLogResult. |
src/config.ts |
Add parseNonNegativeFloat(raw,label,fallback) helper; parse COST_BUDGET_USD→costBudgetUsd (near B2 block line 365-369); add costBudgetUsd to the frozen object (spread block line 415-437). |
src/http/projects.ts |
Add readSync(repoPath) helper (2 execFile calls); extend MakeProjectArgs (line 121) + makeProject (line 129) with ahead/behind/lastCommitMs; call readSync in runDiscovery's per-repo mapWithConcurrency (line 276-280), gated by cfg.projectDirtyCheck. |
src/http/git-log.ts |
New. parseGitLog(stdout,max) (pure) + getGitLog(repoPath,{n,timeoutMs}) (async, execFile, no shell). Modelled on src/http/diff.ts / readDirty (projects.ts:98). |
src/http/digest.ts |
New. buildDigest(live: readonly LiveSessionInfo[], since: number): DigestResult — pure, injected list (mirrors buildProjects injection, projects.ts:387). |
src/session/manager.ts |
In handleStatusLine (line 256): after storing/broadcasting telemetry, run the budget-latch check → notifyService?.notify(session,'budget'). |
src/session/session.ts |
Init budgetNotified: false in createSession object literal (~line 138). Do not re-arm (leave line 150 as-is). |
src/server.ts |
Add GET /projects/log (after /projects/diff, line 678); add GET /digest (after /live-sessions, line 279); extend GET /config/ui (line 728-731) with costBudgetUsd. Import getGitLog, buildDigest. |
public/preview-grid.ts |
renderTelemetryGauge gains costBudgetUsd? param; add tg-cost-warn class on the cost chip (line 224-227). |
public/projects.ts |
normalizeProject (line 236): pass through numeric ahead/behind/lastCommitMs. makeProjectCard (line 454): add sync chip after branch chip. renderProjectDetail (line 709 area, git repos only): mount recent-commits section. |
public/git-log.ts |
New. mountGitLog(container, repoPath) — fetch GET /projects/log, render inert rows via textContent only. |
public/digest.ts |
New. mountDigest(host) — read localStorage last-seen, fetch GET /digest?since=, render dismissible banner, update last-seen. |
public/tabs.ts |
In loadUiConfig (line 248-261): also read costBudgetUsd; store on the instance; pass to renderTelemetryGauge call (line 1391). |
public/main.ts |
mountDigest(...) in the toolbar/app wiring block (~line 51-119). |
public/style.css |
Add .tg-cost-warn, .proj-sync, .proj-commitlog rows, .wya-banner (mirror .tg-ctx-warn, .proj-branch). |
TDD steps (ordered)
Repo style: backend unit tests are node env (default), FE tests declare // @vitest-environment jsdom at the top (test/projects-panel.test.ts:1) and mock @xterm/xterm; route tests spin a real server on a free port (test/integration/projects-endpoint.test.ts). test/manager.test.ts uses a mock NotifyService recording notify(session,cls,token) (line 100-107) and a parseSent(ws) helper.
0. Types (RED→GREEN, compile-only)
- Edit
src/types.tswith all shapes above.npx tsc --noEmitfails where callers/mocks lack new required fields → gives the worklist.Session.budgetNotifiedis required →test/manager.test.ts+src/session/session.tsmust init it.
(b) Cost guard — highest security value, do first
test/config.test.ts: add defaults block —loadConfig({}).costBudgetUsd === 0; overrideCOST_BUDGET_USD:'5.50'→5.5;throwfor'abc'and'-1'(mirror line 156-181). ImplementparseNonNegativeFloat+ wire insrc/config.ts.test/manager.test.ts(extenddescribe('handleStatusLine')line 803): withcfg.costBudgetUsd=1, feed telemetrycostUsd=0.5→notifynot called,session.budgetNotified===false; feedcostUsd=1.2→notifycalled once with(s,'budget'), latchtrue; feedcostUsd=2again → not called again. WithcostBudgetUsd=0→ never called. Implement the latch inmanager.handleStatusLine.test/session.test.ts: assertcreateSession(...).budgetNotified === false. Implement init insession.ts.test/preview-grid.test.ts(jsdom):renderTelemetryGauge(c, {costUsd:6,at:now}, ttl, 5)→ cost chip has classtg-cost-warn; with budget0/undefinedorcostUsd<budget→ no warn class. Implement param.- Route: extend
test/integration(ortest/http) —GET /config/uireturnscostBudgetUsd. Implement inserver.ts:728.
(d) Recent commits
test/http/git-log.test.ts(new):parseGitLog— NUL-record + US-field splitting; empty stdout→[]; malformed record (missing field) skipped; subject truncated at cap;truncatedflag when records===max. (Pure, no spawn.)test/http/git-log.test.ts:getGitLogagainst a real temp repo made withgit init+ 3 commits (pattern fromtest/http/diff.test.ts/ worktrees test) → 3 entries newest-first,n=2→2 +truncated:true.test/integration/projects-log-endpoint.test.ts(new, model onprojects-endpoint.test.ts):GET /projects/log?path=<repo>→ 200 array; missingpath→400; non-git temp dir→404;?n=999→clamped. Implement route inserver.ts.- FE
test/git-log.test.ts(jsdom):mountGitLogrenders rows viatextContent; a commit subject containing<img onerror>appears verbatim (no HTML injection); fetch failure → empty/error inert text. Implementpublic/git-log.ts+ detail-section wiring inpublic/projects.ts.
(a) Sync chip
test/projects.test.ts(extend, node): real temp repo (usesexecFilePalready imported line ~20) with an upstream branch ahead/behind →buildProjectsyieldsahead/behind/lastCommitMs; repo with no upstream → thoseundefined, no throw;projectDirtyCheck:false→ sync skipped (undefined). ImplementreadSync+ fold intorunDiscovery.test/projects-panel.test.ts(jsdom):normalizeProjectpasses numericahead/behind/lastCommitMs, drops non-numbers;makeProjectCardrenders↑2 ↓1chip when set, omits whenundefined/0. Implement FE.
(c) Reconnect digest
test/http/digest.test.ts(new, node):buildDigest([...LiveSessionInfo], since)— counts finished (idle&lastOutputAt>since), needsInput (waiting), stuck, working;totalCostUsdsumstelemetry.costUsd; empty list → zeroes;sincein the future → 0 finished.test/integration/digest-endpoint.test.ts(new): server up,GET /digest?since=0→ 200DigestResult; malformedsince→ treated as 0. Implement route.- FE
test/digest.test.ts(jsdom):mountDigestfetches with the stored last-seen; renders banner only whenfinished+needsInput+stuck>0; dismiss updates localStorage last-seen and hides; fetch failure → no banner (best-effort). Implementpublic/digest.ts+main.tsmount.
Coverage: every new pure function (parseGitLog, buildDigest, readSync via buildProjects, budget latch, gauge warn) has a direct unit test; routes have integration tests. Keeps the ≥80% gate — the new code is mostly pure/tested; the thin server.ts wiring is exercised by the integration tests.
Edge cases & failure modes
- (a) No upstream (
@{u}fatal) → catch →ahead/behindundefined; chip hidden. Detached HEAD →git log -1 --format=%ctstill works (lastCommitMs set),@{u}fails (sync hidden). Empty repo (no commits) → both git calls fail → all undefined. Slow git → 2s timeout kill (reuseGIT_STATUS_TIMEOUT_MS,projects.ts:36). Adds ≤2 spawns/repo bounded byGIT_CONCURRENCY=8; gated off entirely whenprojectDirtyCheck=false.ahead=behind=0(in sync) → chip omitted (only render when >0). - (b)
costUsdundefined in a telemetry frame → skip guard (no crossing). Budget0→ disabled. Latch persists across statusLine frames; a new session gets its own latch (per-Session). DND/notifyDoneinterplay:'budget'is neither'done'nor gated, soshouldSendsends it unless global DND is on (matches stuck). Late-joining device: gets current telemetry viamanager.ts:139-140, derives warn from/config/uibudget — no missed styling. Push disabled (no VAPID) → latch still flips, broadcast still happens, just no push (graceful, like stuck). - (c)
sinceabsent/NaN/negative →0. No sessions → all-zeroDigestResult(banner suppressed client-side). Clock skew /lastOutputAtin future → still counted as finished ifidle(acceptable; coarse "what happened" view). First-ever visit (no localStorage) →since=0, banner may list everything → set last-seen togeneratedAtafter first render so it doesn't re-nag. - (d) Binary/huge subjects:
maxBuffercap + subject truncation. Non-repo/deleted path → 404 (isValidGitDir). Repo with 0 commits →[]. Merge commits/unusual chars in subject → US-field + NUL-record delimiters immune to embedded whitespace.nnon-numeric → clamp to default.
Security
- Path containment:
/projects/logreusesisValidGitDir(server.ts:123) — absolute + isDirectory + has.git(SEC-H7 three-prong), identical to/projects/diff.readSyncruns only against paths already discovered by the bounded BFS scan (scanRepos, symlink/dotdir/node_modules-skipping,projects.ts:148). - No shell, ever: all git calls use
execFile('git', [...])withtimeout+maxBuffer(mirrorsreadDirtyline 100).repoPathis passed ascwd, never interpolated into argv.nis coerced to an int and clamped before reaching argv. - Output is untrusted: commit subjects and digest labels rendered via
textContentonly (SEC-H5, as inpreview-grid.tsandprojects.tsworktree/CLAUDE.md rendering) — zeroinnerHTML. FEnormalizeProject-style narrowing for the new numeric fields (drop non-numbers) and for/digest//projects/logresponses (never trust the API shape). - Origin/CSRF: all four routes are read-only GETs → no Origin guard, consistent with
/projects,/live-sessions,/projects/diff.GET /config/uistays read-only. No state-changing surface is added, so no newrequireAllowedOrigin/ rate-limit needed. (The budget push goes out the existingpushServiceseam — no new inbound route.) - Secrets: cost budget is a non-secret number;
costBudgetUsdis safe to expose in/config/ui. Push payload for'budget'carries only sessionId + cwd-basename label (no cost figure, no terminal bytes) via the existingbuildPayload(push-service.ts:88) — SEC-C5 byte-shuttle boundary preserved. - DoS: sync adds bounded spawns (concurrency 8, 2s timeout, gated by
projectDirtyCheck);/projects/lognclamped ≤50;/digestis O(sessions) over an already-capped table.
Effort & dependencies
~3–4 dev-days total (four independent slices; can be parceled to parallel builders after the shared src/types.ts edit lands):
| Sub | Effort | Notes |
|---|---|---|
| (a) sync chip | ~0.5–0.75d | Backend readSync + fold-in + 2 FE renders. |
| (b) cost guard | ~0.75d | Config float parser, manager latch, gauge warn, /config/ui + tabs.ts wiring. Highest test surface. |
| (c) reconnect digest | ~1d | New pure aggregate + route + new FE banner module + main.ts mount + localStorage last-seen. |
| (d) recent commits | ~1d | New backend git-log module + route + new FE render module + detail-section wiring. |
Dependencies (into these): all four build only on already-shipped infra — buildProjects cache pass (v0.6), NotifyService DI + stuckNotified latch (A5/A1), renderTelemetryGauge (B2), isValidGitDir + getDiff pattern (B1), /config/ui seam (review #4). No dependency on other roadmap items.
Unlocks / synergy: (d)'s getGitLog + NUL-parsing helper and (a)'s upstream detection are reusable by #9 "diff against a base branch" (upstream/@{u} resolution) and #10 "PR + CI status chip" (a per-repo git/gh metadata pass can fold into the same runDiscovery concurrency slot as the sync chip). (c)'s buildDigest gives #8 idle-queued follow-up a ready read-side "which sessions are idle/waiting" aggregate. NotifyClass 'budget' establishes the pattern for any future threshold alerts.