feat(cockpit): quick wins — sync chip, cost budget guard, digest, recent commits (W3)
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).
This commit is contained in:
@@ -91,6 +91,22 @@ function parseNonNegativeInt(
|
||||
return n
|
||||
}
|
||||
|
||||
/** Parse a non-negative float env value (0 allowed), or the fallback when unset. */
|
||||
function parseNonNegativeFloat(
|
||||
raw: string | undefined,
|
||||
label: string,
|
||||
fallback: number,
|
||||
): number {
|
||||
if (raw === undefined) return fallback
|
||||
const n = Number(raw)
|
||||
if (!Number.isFinite(n) || n < 0) {
|
||||
throw new Error(
|
||||
`Invalid config: ${label}=${JSON.stringify(raw)} — must be a non-negative number`,
|
||||
)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
/** Parse a boolean env value ('1'/'true'/'on' → true, '0'/'false'/'off' → false), else fallback. */
|
||||
function parseBool(raw: string | undefined, fallback: boolean): boolean {
|
||||
const v = raw?.trim().toLowerCase()
|
||||
@@ -378,6 +394,9 @@ export function loadConfig(env: EnvLike): Config {
|
||||
DEFAULT_STATUSLINE_TTL_MS,
|
||||
)
|
||||
|
||||
// W3 quick-wins (b) cost budget guard — dollars, float ≥ 0; 0/unset = disabled.
|
||||
const costBudgetUsd = parseNonNegativeFloat(env['COST_BUDGET_USD'], 'COST_BUDGET_USD', 0)
|
||||
|
||||
// B3 git worktree creation
|
||||
const worktreeEnabled = parseBool(env['WORKTREE_ENABLED'], true)
|
||||
const worktreeRoot = env['WORKTREE_ROOT'] || undefined // undefined → computed at creation time
|
||||
@@ -452,6 +471,7 @@ export function loadConfig(env: EnvLike): Config {
|
||||
ghEnabled,
|
||||
ghTimeoutMs,
|
||||
statuslineTtlMs,
|
||||
costBudgetUsd,
|
||||
worktreeEnabled,
|
||||
worktreeRoot,
|
||||
worktreeTimeoutMs,
|
||||
|
||||
79
src/http/digest.ts
Normal file
79
src/http/digest.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* src/http/digest.ts (W3 quick-wins c) — "while you were away" reconnect digest.
|
||||
*
|
||||
* A PURE read-side aggregate over the live-session list (injected, like
|
||||
* buildProjects) plus each session's in-memory telemetry/status. No new state:
|
||||
* it only projects what the manager already tracks into a compact summary the FE
|
||||
* shows as a banner on (re)connect.
|
||||
*
|
||||
* `since` is a client's last-seen epoch-ms watermark: a session counts as
|
||||
* `finished` when it is idle AND produced output after `since`. A bad/absent
|
||||
* `since` clamps to 0 ("everything is new").
|
||||
*/
|
||||
|
||||
import path from 'node:path'
|
||||
import type { DigestResult, DigestSession, LiveSessionInfo } from '../types.js'
|
||||
|
||||
/** Last path segment of a cwd (the session "title"), or undefined. */
|
||||
function lastSegment(cwd: string | null): string | undefined {
|
||||
if (cwd === null || cwd === '') return undefined
|
||||
return cwd.split(path.sep).filter(Boolean).pop()
|
||||
}
|
||||
|
||||
/** Clamp `since` to a finite, non-negative number (bad/absent → 0). */
|
||||
export function clampSince(since: unknown): number {
|
||||
const n = typeof since === 'number' ? since : Number(since)
|
||||
return Number.isFinite(n) && n >= 0 ? n : 0
|
||||
}
|
||||
|
||||
/** Project one live session into its digest row. */
|
||||
function toDigestSession(s: LiveSessionInfo, since: number): DigestSession {
|
||||
const title = lastSegment(s.cwd)
|
||||
const costUsd = s.telemetry?.costUsd
|
||||
const lastOutputAt = s.lastOutputAt
|
||||
const finished = s.status === 'idle' && lastOutputAt !== undefined && lastOutputAt > since
|
||||
return {
|
||||
id: s.id,
|
||||
...(title !== undefined ? { title } : {}),
|
||||
status: s.status,
|
||||
...(costUsd !== undefined ? { costUsd } : {}),
|
||||
...(lastOutputAt !== undefined ? { lastOutputAt } : {}),
|
||||
finished,
|
||||
needsInput: s.status === 'waiting',
|
||||
stuck: s.status === 'stuck',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the reconnect digest from the live-session list. Pure — the list is
|
||||
* injected (e.g. `manager.list()`). Empty list → all-zero aggregate. Never throws.
|
||||
*/
|
||||
export function buildDigest(live: readonly LiveSessionInfo[], since: number): DigestResult {
|
||||
const clampedSince = clampSince(since)
|
||||
const sessions = live.map((s) => toDigestSession(s, clampedSince))
|
||||
|
||||
let finished = 0
|
||||
let needsInput = 0
|
||||
let stuck = 0
|
||||
let working = 0
|
||||
let totalCostUsd = 0
|
||||
for (const d of sessions) {
|
||||
if (d.finished) finished += 1
|
||||
if (d.needsInput) needsInput += 1
|
||||
if (d.stuck) stuck += 1
|
||||
if (d.status === 'working') working += 1
|
||||
if (d.costUsd !== undefined) totalCostUsd += d.costUsd
|
||||
}
|
||||
|
||||
return {
|
||||
since: clampedSince,
|
||||
generatedAt: Date.now(),
|
||||
total: sessions.length,
|
||||
finished,
|
||||
needsInput,
|
||||
stuck,
|
||||
working,
|
||||
totalCostUsd,
|
||||
sessions,
|
||||
}
|
||||
}
|
||||
109
src/http/git-log.ts
Normal file
109
src/http/git-log.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* src/http/git-log.ts (W3 quick-wins d) — read-only recent-commit log.
|
||||
*
|
||||
* The server stays a byte-shuttle: this is an out-of-band side-channel that runs
|
||||
* `git log` in a directory and PARSES its output into CommitLogEntry[]. Parsing
|
||||
* lives ONLY here; public/git-log.ts is render-only (mirrors diff.ts / gh.ts).
|
||||
*
|
||||
* Delimiter design (robust against nasty subjects): the format is
|
||||
* %h %x1f %ct %x1f %s with -z (records separated by NUL)
|
||||
* so a US (0x1f) field separator + a NUL (0x00) record separator can never be
|
||||
* corrupted by a subject containing tabs, spaces or newlines. We never parse the
|
||||
* fragile `--oneline` shape.
|
||||
*
|
||||
* Security (mirrors diff.ts):
|
||||
* - execFile('git', [...]) with NO shell; timeout + maxBuffer bound DoS.
|
||||
* - `repoPath` is the cwd, never interpolated into argv; `n` is coerced to an
|
||||
* int and clamped BEFORE reaching argv. Path→repo validation is the route's
|
||||
* job (isValidGitDir, SEC-H7), exactly like /projects/diff.
|
||||
* - parseGitLog NEVER throws: malformed records are skipped; getGitLog is
|
||||
* best-effort and returns an empty result rather than rejecting.
|
||||
* - subjects are carried verbatim and rendered as inert text (textContent) in
|
||||
* the FE — never HTML.
|
||||
*/
|
||||
|
||||
import { execFile } from 'node:child_process'
|
||||
import { promisify } from 'node:util'
|
||||
import type { CommitLogEntry, GitLogResult } from '../types.js'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
/** Hard cap on the number of commits a single request may return (DoS bound). */
|
||||
export const GIT_LOG_MAX = 50
|
||||
/** Default number of commits when the caller does not specify `n`. */
|
||||
export const GIT_LOG_DEFAULT = 20
|
||||
/** Cap a single commit subject so a pathological message can't bloat the payload. */
|
||||
const SUBJECT_MAX_LEN = 500
|
||||
/** Bound the captured stdout (DoS guard); 1 MB is ample for ≤50 subjects. */
|
||||
const GIT_LOG_MAX_BUFFER = 1024 * 1024
|
||||
/** Field separator (US, 0x1f) and record separator (NUL, 0x00). */
|
||||
const FIELD_SEP = '\x1f'
|
||||
const RECORD_SEP = '\x00'
|
||||
|
||||
/**
|
||||
* Clamp a possibly-junk `n` to an integer in [1, GIT_LOG_MAX]. Non-numeric /
|
||||
* missing → GIT_LOG_DEFAULT. Never throws.
|
||||
*/
|
||||
export function clampLogCount(n: unknown): number {
|
||||
const parsed = typeof n === 'number' ? n : Number.parseInt(String(n ?? ''), 10)
|
||||
if (!Number.isFinite(parsed)) return GIT_LOG_DEFAULT
|
||||
const floored = Math.floor(parsed)
|
||||
if (floored < 1) return 1
|
||||
if (floored > GIT_LOG_MAX) return GIT_LOG_MAX
|
||||
return floored
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `git log -z --format=%h%x1f%ct%x1f%s` output into CommitLogEntry[].
|
||||
* Records are NUL-separated; fields are US-separated. A record missing a field
|
||||
* or with a non-numeric timestamp is skipped (never throws). `max` caps the
|
||||
* returned entries and drives the `truncated` flag (records === max ⇒ there may
|
||||
* be more). Empty stdout → an empty, non-truncated result.
|
||||
*/
|
||||
export function parseGitLog(stdout: string, max: number): GitLogResult {
|
||||
if (typeof stdout !== 'string' || stdout.length === 0) {
|
||||
return { commits: [], truncated: false }
|
||||
}
|
||||
const records = stdout.split(RECORD_SEP).filter((r) => r.length > 0)
|
||||
const commits: CommitLogEntry[] = []
|
||||
for (const record of records) {
|
||||
const parts = record.split(FIELD_SEP)
|
||||
if (parts.length < 3) continue // malformed — missing a field
|
||||
const hash = (parts[0] ?? '').trim()
|
||||
const secs = Number.parseInt(parts[1] ?? '', 10)
|
||||
// Subject may (in theory) contain a US char; re-join the tail so it is intact.
|
||||
const subject = parts.slice(2).join(FIELD_SEP)
|
||||
if (hash === '' || !Number.isFinite(secs) || secs < 0) continue
|
||||
commits.push({
|
||||
hash,
|
||||
at: secs * 1000,
|
||||
subject: subject.length > SUBJECT_MAX_LEN ? subject.slice(0, SUBJECT_MAX_LEN) : subject,
|
||||
})
|
||||
}
|
||||
const truncated = commits.length >= max && max > 0
|
||||
return { commits: max > 0 ? commits.slice(0, max) : commits, truncated }
|
||||
}
|
||||
|
||||
export interface GetGitLogOptions {
|
||||
n?: number
|
||||
timeoutMs: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a repo's recent commits (newest first) as a structured GitLogResult.
|
||||
* `repoPath` must already be a validated absolute git directory (route layer,
|
||||
* SEC-H7). Best-effort: any git failure yields an empty result, never throws.
|
||||
*/
|
||||
export async function getGitLog(repoPath: string, opts: GetGitLogOptions): Promise<GitLogResult> {
|
||||
const n = clampLogCount(opts.n)
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
'git',
|
||||
['log', '--no-color', '-z', '-n', String(n), '--format=%h%x1f%ct%x1f%s'],
|
||||
{ cwd: repoPath, timeout: opts.timeoutMs, maxBuffer: GIT_LOG_MAX_BUFFER },
|
||||
)
|
||||
return parseGitLog(stdout, n)
|
||||
} catch {
|
||||
return { commits: [], truncated: false }
|
||||
}
|
||||
}
|
||||
@@ -108,6 +108,50 @@ async function readDirty(repoPath: string): Promise<boolean | undefined> {
|
||||
}
|
||||
}
|
||||
|
||||
/** W3 quick-wins (a): best-effort ahead/behind vs upstream + last-commit time.
|
||||
* Two read-only git calls (no shell), each bounded by timeout + maxBuffer:
|
||||
* 1. `git rev-list --count --left-right @{u}...HEAD` → "<behind>\t<ahead>"
|
||||
* (left = commits on @{u} not HEAD = behind; right = HEAD not @{u} = ahead).
|
||||
* No upstream (`@{u}` fatal) / detached / empty repo → ahead/behind undefined.
|
||||
* 2. `git log -1 --format=%ct` → HEAD commit unix seconds → lastCommitMs (×1000).
|
||||
* Every field degrades to undefined independently; never throws. */
|
||||
async function readSync(repoPath: string): Promise<{
|
||||
ahead?: number
|
||||
behind?: number
|
||||
lastCommitMs?: number
|
||||
}> {
|
||||
const out: { ahead?: number; behind?: number; lastCommitMs?: number } = {}
|
||||
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
'git',
|
||||
['rev-list', '--count', '--left-right', '@{u}...HEAD'],
|
||||
{ cwd: repoPath, timeout: GIT_STATUS_TIMEOUT_MS, maxBuffer: GIT_STATUS_MAX_BUFFER },
|
||||
)
|
||||
const parts = stdout.trim().split(/\s+/)
|
||||
const behind = Number.parseInt(parts[0] ?? '', 10)
|
||||
const ahead = Number.parseInt(parts[1] ?? '', 10)
|
||||
if (Number.isFinite(behind) && behind >= 0) out.behind = behind
|
||||
if (Number.isFinite(ahead) && ahead >= 0) out.ahead = ahead
|
||||
} catch {
|
||||
// no upstream / detached / empty repo → leave ahead/behind undefined
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout } = await execFileAsync('git', ['log', '-1', '--format=%ct'], {
|
||||
cwd: repoPath,
|
||||
timeout: GIT_STATUS_TIMEOUT_MS,
|
||||
maxBuffer: GIT_STATUS_MAX_BUFFER,
|
||||
})
|
||||
const secs = Number.parseInt(stdout.trim(), 10)
|
||||
if (Number.isFinite(secs) && secs >= 0) out.lastCommitMs = secs * 1000
|
||||
} catch {
|
||||
// empty repo (no commits) → leave lastCommitMs undefined
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
/** True iff `<dir>/.git` exists (file or directory). */
|
||||
async function hasGitEntry(dir: string): Promise<boolean> {
|
||||
try {
|
||||
@@ -124,6 +168,9 @@ interface MakeProjectArgs {
|
||||
readonly branch?: string
|
||||
readonly dirty?: boolean
|
||||
readonly lastActiveMs?: number
|
||||
readonly ahead?: number
|
||||
readonly behind?: number
|
||||
readonly lastCommitMs?: number
|
||||
}
|
||||
|
||||
function makeProject(args: MakeProjectArgs): ProjectInfo {
|
||||
@@ -134,6 +181,9 @@ function makeProject(args: MakeProjectArgs): ProjectInfo {
|
||||
branch: args.branch,
|
||||
dirty: args.dirty,
|
||||
lastActiveMs: args.lastActiveMs,
|
||||
ahead: args.ahead,
|
||||
behind: args.behind,
|
||||
lastCommitMs: args.lastCommitMs,
|
||||
sessions: [],
|
||||
}
|
||||
}
|
||||
@@ -275,8 +325,11 @@ async function runDiscovery(cfg: Config): Promise<ProjectInfo[]> {
|
||||
const repoPaths = await scanRepos(cfg.projectRoots, cfg.projectScanDepth)
|
||||
const repos = await mapWithConcurrency(repoPaths, GIT_CONCURRENCY, async (repoPath) => {
|
||||
const branch = await readBranch(repoPath)
|
||||
// W3(a): the sync chip (ahead/behind + last-commit) rides the same per-repo
|
||||
// git budget as the dirty check — gated by projectDirtyCheck, best-effort.
|
||||
const dirty = cfg.projectDirtyCheck ? await readDirty(repoPath) : undefined
|
||||
return makeProject({ path: repoPath, isGit: true, branch, dirty })
|
||||
const sync = cfg.projectDirtyCheck ? await readSync(repoPath) : {}
|
||||
return makeProject({ path: repoPath, isGit: true, branch, dirty, ...sync })
|
||||
})
|
||||
const merged = await mergeHistory(repos)
|
||||
return dropParentFolders(dedupByPath(merged))
|
||||
|
||||
@@ -40,6 +40,8 @@ import { listSessions } from './http/history.js'
|
||||
import { buildProjects, buildProjectDetail } from './http/projects.js'
|
||||
import { openInEditor, openFileInEditor } from './http/editor.js'
|
||||
import { getDiff, isPlausibleRev } from './http/diff.js'
|
||||
import { getGitLog } from './http/git-log.js'
|
||||
import { buildDigest, clampSince } from './http/digest.js'
|
||||
import { getPrStatus } from './http/gh.js'
|
||||
import { parseStatusLine } from './http/statusline.js'
|
||||
import { createWorktree } from './http/worktrees.js'
|
||||
@@ -326,6 +328,15 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
|
||||
res.json(manager.list())
|
||||
})
|
||||
|
||||
// ── W3 quick-wins (c): "while you were away" reconnect digest (read-only) ──
|
||||
// A pure read-side aggregate over manager.list() + in-memory telemetry/status;
|
||||
// no Origin guard (same threat model as /live-sessions). `?since=<epochMs>` is
|
||||
// the client's last-seen watermark (bad/absent → 0 = "everything is new").
|
||||
app.get('/digest', (req, res) => {
|
||||
const since = clampSince(req.query['since'])
|
||||
res.json(buildDigest(manager.list(), since))
|
||||
})
|
||||
|
||||
// Projects (v0.6 Project Manager) — discovery-only; no Origin guard (read-only, like /live-sessions).
|
||||
app.get('/projects', async (_req, res) => {
|
||||
try {
|
||||
@@ -820,6 +831,30 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
|
||||
}
|
||||
})
|
||||
|
||||
// ── W3 quick-wins (d): read-only recent-commit log (no Origin guard) ──────
|
||||
// Same three-prong path validation (isValidGitDir, SEC-H7) as /projects/diff.
|
||||
// `?n=<int>` is clamped to [1, GIT_LOG_MAX] inside getGitLog; `path` missing →
|
||||
// 400, non-git dir → 404, git failure → best-effort empty (getGitLog) → 200.
|
||||
app.get('/projects/log', async (req, res) => {
|
||||
const target = req.query['path']
|
||||
if (typeof target !== 'string' || target === '') {
|
||||
res.status(400).json({ error: 'path query parameter is required' })
|
||||
return
|
||||
}
|
||||
if (!(await isValidGitDir(target))) {
|
||||
res.status(404).json({ error: 'project not found' }) // SEC-H7 three-prong
|
||||
return
|
||||
}
|
||||
const rawN = req.query['n']
|
||||
const n = typeof rawN === 'string' ? Number.parseInt(rawN, 10) : undefined
|
||||
try {
|
||||
res.json(await getGitLog(target, { n, timeoutMs: cfg.diffTimeoutMs }))
|
||||
} catch (err) {
|
||||
console.error('[server] /projects/log failed:', err instanceof Error ? err.message : String(err))
|
||||
res.status(500).json({ error: 'failed to read git log' })
|
||||
}
|
||||
})
|
||||
|
||||
// ── W3 read-only PR + CI status (no Origin guard; same threat model as /projects) ─
|
||||
// Out-of-band side-channel: spawns the host's `gh` CLI to read the current
|
||||
// branch's PR + statusCheckRollup. Unlike the local git side-channels, gh makes
|
||||
@@ -895,7 +930,12 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
|
||||
|
||||
// ── GET /config/ui (review #4) — client-readable UI config (read-only) ────
|
||||
app.get('/config/ui', (_req, res) => {
|
||||
const uiConfig: UiConfig = { allowAutoMode: cfg.allowAutoMode }
|
||||
const uiConfig: UiConfig = {
|
||||
allowAutoMode: cfg.allowAutoMode,
|
||||
// W3(b): expose the cost budget only when set (>0) so the FE can derive
|
||||
// cost-overage warn styling; a non-secret number, safe over /config/ui.
|
||||
...(cfg.costBudgetUsd > 0 ? { costBudgetUsd: cfg.costBudgetUsd } : {}),
|
||||
}
|
||||
res.json(uiConfig)
|
||||
})
|
||||
|
||||
|
||||
@@ -265,6 +265,29 @@ export function createSessionManager(
|
||||
if (session === undefined) return;
|
||||
session.telemetry = telemetry;
|
||||
broadcast(session, { type: 'telemetry', telemetry });
|
||||
maybeAlertBudget(session, telemetry);
|
||||
}
|
||||
|
||||
/**
|
||||
* W3 quick-wins (b): fire a one-shot cost-budget alert when a session's cost
|
||||
* first crosses COST_BUDGET_USD. Mirrors the A5 stuck-latch shape but is NEVER
|
||||
* re-armed (cost is monotonic) — so it fires at most once per session. Disabled
|
||||
* when the budget is 0/unset or the frame carries no cost.
|
||||
*
|
||||
* No new ServerMessage variant: the "warning broadcast" is the telemetry frame
|
||||
* already sent above (clients derive the warn from costUsd >= costBudgetUsd via
|
||||
* GET /config/ui). The one distinct new action on crossing is a 'budget' push.
|
||||
*/
|
||||
function maybeAlertBudget(session: Session, telemetry: StatusTelemetry): void {
|
||||
if (cfg.costBudgetUsd <= 0) return; // disabled
|
||||
if (session.budgetNotified) return; // already alerted (latch)
|
||||
const cost = telemetry.costUsd;
|
||||
if (cost === undefined || cost < cfg.costBudgetUsd) return; // no crossing
|
||||
|
||||
session.budgetNotified = true;
|
||||
void notifyService?.notify(session, 'budget').catch((err: unknown) => {
|
||||
console.error('[manager] budget notification failed', err);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -136,6 +136,7 @@ export function createSession(
|
||||
// v0.7 Walk-away Workbench fields (T-spawn-env):
|
||||
timeline: Object.freeze([] as TimelineEvent[]),
|
||||
stuckNotified: false, // A5: re-armed to false by each pty output
|
||||
budgetNotified: false, // W3(b): cost-budget one-shot latch, never re-armed
|
||||
telemetry: null, // B2: updated by manager.handleStatusLine
|
||||
// W2: inject follow-up queue — empty at spawn; replaced wholesale by manager.
|
||||
queue: Object.freeze([] as string[]),
|
||||
|
||||
62
src/types.ts
62
src/types.ts
@@ -66,6 +66,8 @@ export interface Config {
|
||||
readonly ghTimeoutMs: number; // GH_TIMEOUT_MS, default 8000 (network — larger than diff)
|
||||
// B2 statusLine telemetry
|
||||
readonly statuslineTtlMs: number; // STATUSLINE_TTL_MS, default 30000
|
||||
// W3 quick-wins (b) cost budget guard
|
||||
readonly costBudgetUsd: number; // COST_BUDGET_USD, default 0 (0/unset = disabled)
|
||||
// B3 git worktree creation
|
||||
readonly worktreeEnabled: boolean; // WORKTREE_ENABLED, default true
|
||||
readonly worktreeRoot: string | undefined; // WORKTREE_ROOT (undefined → computed)
|
||||
@@ -257,6 +259,10 @@ export interface Session {
|
||||
/** A5: true once a stuck alert fired this round; re-armed (→false) by the next
|
||||
* pty.onData so each silent round alerts at most once. */
|
||||
stuckNotified: boolean;
|
||||
/** W3 quick-wins (b): true once the cost-budget alert fired for this session.
|
||||
* One-shot latch — NEVER re-armed (cost is monotonic), so the budget push +
|
||||
* warning broadcast happen at most once per session. */
|
||||
budgetNotified: boolean;
|
||||
/** B2: latest statusLine telemetry for this session; null until first report. */
|
||||
telemetry: StatusTelemetry | null;
|
||||
/** W2: bounded FIFO of verbatim byte strings to inject when Claude next goes
|
||||
@@ -322,6 +328,10 @@ export interface ProjectInfo {
|
||||
branch?: string; // current branch (git repos only)
|
||||
dirty?: boolean; // uncommitted changes (when projectDirtyCheck)
|
||||
lastActiveMs?: number; // newest ~/.claude/projects mtime for this cwd; sort key
|
||||
// W3 quick-wins (a) sync chip — best-effort git ahead/behind vs @{u} + last commit.
|
||||
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[]; // running sessions in this project (1:N; may be empty)
|
||||
}
|
||||
|
||||
@@ -431,8 +441,9 @@ export type PermissionMode = 'default' | 'acceptEdits' | 'plan' | 'auto';
|
||||
|
||||
/* ── A1 push notifications (§3.3, §A1) ── */
|
||||
|
||||
/** The three proactive signals pushed to the phone (§3.3 / §A1). */
|
||||
export type NotifyClass = 'needs-input' | 'done' | 'stuck';
|
||||
/** The proactive signals pushed to the phone (§3.3 / §A1). 'budget' (W3
|
||||
* quick-wins b) fires once when a session's cost crosses COST_BUDGET_USD. */
|
||||
export type NotifyClass = 'needs-input' | 'done' | 'stuck' | 'budget';
|
||||
|
||||
/** Outbound push body — ONE shape: push-service sends it, sw-push.js reads `cls`
|
||||
* (§3.3 review #3). Minimal by design: no raw terminal output, no secrets.
|
||||
@@ -601,6 +612,53 @@ export interface UiPrefs {
|
||||
* permission mode when the server forbids it (SEC-M5). */
|
||||
export interface UiConfig {
|
||||
allowAutoMode: boolean;
|
||||
/** W3 quick-wins (b): the cost-budget threshold (USD). Present when > 0 so the
|
||||
* FE can derive cost-overage warn styling client-side; omitted when disabled. */
|
||||
costBudgetUsd?: number;
|
||||
}
|
||||
|
||||
/* ── W3 quick-wins (c) reconnect digest (GET /digest) ── */
|
||||
|
||||
/** One session in the "while you were away" digest — a read-side projection of a
|
||||
* live session plus its latest telemetry/status. All fields derived, no new state. */
|
||||
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'
|
||||
}
|
||||
|
||||
/** GET /digest result — an aggregate over manager.list() since a client's
|
||||
* last-seen timestamp. Pure read (no new state); empty when no sessions. */
|
||||
export interface DigestResult {
|
||||
since: number;
|
||||
generatedAt: number;
|
||||
total: number;
|
||||
finished: number;
|
||||
needsInput: number;
|
||||
stuck: number;
|
||||
working: number;
|
||||
totalCostUsd: number;
|
||||
sessions: DigestSession[];
|
||||
}
|
||||
|
||||
/* ── W3 quick-wins (d) recent-commits log (GET /projects/log) ── */
|
||||
|
||||
/** One commit from `git log` (NUL-record, US-field delimited). `at` = %ct*1000. */
|
||||
export interface CommitLogEntry {
|
||||
hash: string;
|
||||
at: number;
|
||||
subject: string;
|
||||
}
|
||||
|
||||
/** GET /projects/log result. `truncated` = more commits exist beyond the cap. */
|
||||
export interface GitLogResult {
|
||||
commits: CommitLogEntry[];
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
/* ─────────────────────── frontend (§5/§6.3) ──────────────────── */
|
||||
|
||||
Reference in New Issue
Block a user