feat(v0.7): Walk-away Workbench (Band A + B) — multi-agent parallel build
Implements docs/PLAN_WALKAWAY_WORKBENCH.md (27 tasks, waves R0→W0→W1×14→W2→W3→W4)
via module-builder agents. 23 tasks built, 0 blocked.
Band A (finish the walk-away loop): A1 Web Push + lock-screen approve/deny
(web-push dep), A2 voice dictation, A3 quick-reply chips + saved-prompt palette,
A4 activity timeline, A5 stuck/idle alert.
Band B (workbench above the terminal): B1 read-only git diff viewer, B2 statusLine
telemetry → per-tab cost/context/PR gauges, B3 create git worktrees from the UI,
B4 plan-mode / permission-mode relay.
New: src/push/* (subscription store + VAPID push), src/http/{diff,statusline}.ts,
src/session/timeline.ts, public/{diff,timeline,quickreply,push-ui,...}.ts, sw-push,
statusLine script; extends hook intake, manager, server routes (Origin/CSRF guards
+ per-IP rate limits on state-changing ones; loopback-only ingest), terminal-session,
tabs, projects detail, service worker, setup-hooks (statusLine + ntfy bridge).
Orchestrator reconciled a W0 contract gap: added the 21 v0.7 Config fields to the
Config interface in types.ts (T-types had left them only in config.ts's return).
Verified: both tsc clean, full vitest + coverage 91.4/84.1/92.2/93.4 (≥80×4),
build:web OK. W4 review: no CRITICAL/HIGH; all security checks pass. Follow-ups
(non-blocking): move approve.mode validation into parseClientMessage, drop CSP
ws:/wss: wildcard, validate worktree base ref, +2 targeted tests.
This commit is contained in:
231
src/types.ts
231
src/types.ts
@@ -40,6 +40,35 @@ export interface Config {
|
||||
readonly projectScanTtlMs: number; // PROJECT_SCAN_TTL, default 10000 (discovery cache)
|
||||
readonly projectDirtyCheck: boolean; // PROJECT_DIRTY_CHECK, default true (git status per repo)
|
||||
readonly editorCmd: string; // EDITOR_CMD, default 'code' — POST /open-in-editor launches `<editorCmd> <repoPath>` on the host
|
||||
// v0.7 Walk-away Workbench — 21 new fields (impl: src/config.ts T-config)
|
||||
// A1 push notifications (vapid* are SECRETS — never log/expose to clients)
|
||||
readonly vapidPublicKey: string | undefined; // VAPID_PUBLIC_KEY
|
||||
readonly vapidPrivateKey: string | undefined; // VAPID_PRIVATE_KEY (secret)
|
||||
readonly vapidSubject: string | undefined; // VAPID_SUBJECT (mailto:/url)
|
||||
readonly pushStorePath: string; // PUSH_STORE_PATH, default ~/.web-terminal-push-subs.json
|
||||
readonly pushMaxSubs: number; // PUSH_MAX_SUBS, default 50
|
||||
readonly notifyDone: boolean; // NOTIFY_DONE, default true
|
||||
readonly notifyDnd: boolean; // NOTIFY_DND, default false
|
||||
readonly decisionTokenTtlMs: number; // DECISION_TOKEN_TTL_MS, default = permTimeoutMs
|
||||
// A4 activity timeline
|
||||
readonly timelineMax: number; // TIMELINE_MAX, default 200
|
||||
readonly timelineEnabled: boolean; // TIMELINE_ENABLED, default true
|
||||
// A5 stuck detection
|
||||
readonly stuckTtlMs: number; // STUCK_TTL (seconds env) → ms, default 600s
|
||||
readonly stuckAlert: boolean; // STUCK_ALERT, default true
|
||||
// B1 git diff
|
||||
readonly diffTimeoutMs: number; // DIFF_TIMEOUT_MS, default 2000
|
||||
readonly diffMaxBytes: number; // DIFF_MAX_BYTES, default 2MB
|
||||
readonly diffMaxFiles: number; // DIFF_MAX_FILES, default 300
|
||||
// B2 statusLine telemetry
|
||||
readonly statuslineTtlMs: number; // STATUSLINE_TTL_MS, default 30000
|
||||
// B3 git worktree creation
|
||||
readonly worktreeEnabled: boolean; // WORKTREE_ENABLED, default true
|
||||
readonly worktreeRoot: string | undefined; // WORKTREE_ROOT (undefined → computed)
|
||||
readonly worktreeTimeoutMs: number; // WORKTREE_TIMEOUT_MS, default 10000
|
||||
// B4 permission-mode relay
|
||||
readonly defaultPermissionMode: PermissionMode; // DEFAULT_PERMISSION_MODE, default 'default'
|
||||
readonly allowAutoMode: boolean; // ALLOW_AUTO_MODE, default false (SEC-M5)
|
||||
}
|
||||
|
||||
/** process.env is structurally assignable to this; keeps the file free of `NodeJS.*`. */
|
||||
@@ -51,26 +80,43 @@ export type EnvLike = Readonly<Record<string, string | undefined>>;
|
||||
/* ─────────────────────── protocol (§3.2) ─────────────────────── */
|
||||
|
||||
/** client → server. approve/reject (H3) resolve a held PermissionRequest.
|
||||
* attach.cwd (M6) = spawn a new session in this directory ("new tab here"). */
|
||||
* attach.cwd (M6) = spawn a new session in this directory ("new tab here").
|
||||
* approve.mode (B4) = permission mode to write back when resolving a `plan`
|
||||
* gate (e.g. approve+auto → 'acceptEdits'); omitted for ordinary tool gates. */
|
||||
export type ClientMessage =
|
||||
| { type: 'attach'; sessionId: string | null; cwd?: string }
|
||||
| { type: 'input'; data: string }
|
||||
| { type: 'resize'; cols: number; rows: number }
|
||||
| { type: 'approve' }
|
||||
| { type: 'approve'; mode?: PermissionMode }
|
||||
| { type: 'reject' };
|
||||
|
||||
/** Claude Code activity, derived from hooks (H2). 'unknown' = no hook signal yet. */
|
||||
export type ClaudeStatus = 'working' | 'waiting' | 'idle' | 'unknown';
|
||||
/** Claude Code activity, derived from hooks (H2). 'unknown' = no hook signal yet.
|
||||
* 'stuck' (A5) = output silent past STUCK_TTL while not idle/exited; set by
|
||||
* manager.sweepStuck and re-armed to 'working' on the next pty output. */
|
||||
export type ClaudeStatus = 'working' | 'waiting' | 'idle' | 'unknown' | 'stuck';
|
||||
|
||||
/** Which kind of held approval a `status` carries (B4). 'plan' = an ExitPlanMode
|
||||
* gate (three-way approve/auto/keep-planning); 'tool' = an ordinary tool gate. */
|
||||
export type PermissionGate = 'tool' | 'plan';
|
||||
|
||||
/** server → client. exit.code = shell code; -1 when spawn never succeeded (M4).
|
||||
* exit.reason optional normally, REQUIRED on spawn failure / abnormal exit.
|
||||
* status (H2/H3) = Claude Code activity; `pending` true when a tool approval
|
||||
* is held server-side and the client can approve/reject it. */
|
||||
* is held server-side and the client can approve/reject it; `gate` (B4) tells
|
||||
* the client whether the held approval is a 'tool' or 'plan' gate.
|
||||
* telemetry (B2) = latest statusLine telemetry broadcast for this session. */
|
||||
export type ServerMessage =
|
||||
| { type: 'attached'; sessionId: string }
|
||||
| { type: 'output'; data: string }
|
||||
| { type: 'exit'; code: number; reason?: string }
|
||||
| { type: 'status'; status: ClaudeStatus; detail?: string; pending?: boolean };
|
||||
| {
|
||||
type: 'status';
|
||||
status: ClaudeStatus;
|
||||
detail?: string;
|
||||
pending?: boolean;
|
||||
gate?: PermissionGate;
|
||||
}
|
||||
| { type: 'telemetry'; telemetry: StatusTelemetry };
|
||||
|
||||
/** parseClientMessage result — never throws; errors flow here (§5.3). */
|
||||
export type ParseResult =
|
||||
@@ -172,6 +218,15 @@ export interface Session {
|
||||
cwd: string | null;
|
||||
/** tmux session name (H1) when running under tmux, else null. */
|
||||
readonly tmuxName: string | null;
|
||||
/** Bounded, timestamped activity ring (A4); newest appended, oldest evicted at
|
||||
* cfg.timelineMax. Mutable runtime handle on the immutable meta (like buffer).
|
||||
* Replaced wholesale via appendEvent (never mutated in place). */
|
||||
timeline: readonly TimelineEvent[];
|
||||
/** 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;
|
||||
/** B2: latest statusLine telemetry for this session; null until first report. */
|
||||
telemetry: StatusTelemetry | null;
|
||||
readonly pty: IPty;
|
||||
}
|
||||
|
||||
@@ -196,6 +251,7 @@ export interface LiveSessionInfo {
|
||||
cwd: string | null;
|
||||
cols: number; // current PTY size (for the manage page)
|
||||
rows: number;
|
||||
telemetry?: StatusTelemetry | null; // B2: latest telemetry for the thumbnail wall
|
||||
}
|
||||
|
||||
/* ───────────────── project manager (v0.6, §4.3 FEATURE doc) ──────────────── */
|
||||
@@ -262,20 +318,179 @@ export interface SessionManager {
|
||||
/** Kill a session by id (manage page): close its clients, kill the PTY, drop it.
|
||||
* Returns true if a session was found and killed. */
|
||||
killById(id: string): boolean;
|
||||
/** Set a session's Claude status (from a hook) and push it to the attached ws (H2/H3). */
|
||||
/** Set a session's Claude status (from a hook), append a timeline event (A4)
|
||||
* when eventClass is supplied + timeline is enabled, and broadcast to all
|
||||
* attached clients (H2/H3). gate (B4) marks a 'tool'/'plan' approval gate. */
|
||||
handleHookEvent(
|
||||
sessionId: string,
|
||||
status: ClaudeStatus,
|
||||
detail?: string,
|
||||
pending?: boolean,
|
||||
gate?: PermissionGate,
|
||||
eventClass?: string,
|
||||
toolName?: string,
|
||||
): void;
|
||||
/** B2: store the latest statusLine telemetry for a session and broadcast a
|
||||
* `telemetry` message to all attached clients. */
|
||||
handleStatusLine(id: string, telemetry: StatusTelemetry): void;
|
||||
/** A5: on the existing reaper tick (no new timer, H4), mark live non-idle
|
||||
* sessions whose output has been silent past STUCK_TTL as 'stuck', broadcast,
|
||||
* and notify once per silent round (re-armed by the next pty output). */
|
||||
sweepStuck(now: number): void;
|
||||
/** reclaim when now - max(detachedAt, lastOutputAt) > idleTtlMs (M3). Returns count. */
|
||||
reapIdle(now: number): number;
|
||||
shutdown(): void;
|
||||
}
|
||||
|
||||
// impl anchor [src/session/manager.ts]:
|
||||
// export function createSessionManager(cfg: Config): SessionManager
|
||||
// export function createSessionManager(cfg: Config, notifyService?: NotifyService): SessionManager
|
||||
// notifyService is injected (DI, M5) so the manager never imports push-service
|
||||
// directly (avoids a manager↔push-service cycle); sweepStuck uses it for 'stuck'.
|
||||
|
||||
/* ───────────── v0.7 Walk-away Workbench (FEATURE_WALKAWAY_WORKBENCH) ─────────────
|
||||
*
|
||||
* The five FE↔BE contract resolutions from PLAN_WALKAWAY_WORKBENCH §3, frozen
|
||||
* here so the frontend and backend agree on ONE spelling/shape. Field names for
|
||||
* StatusTelemetry (B2) and the value set for PermissionMode (B4) are derived
|
||||
* from the R0 statusLine / --permission-mode research findings.
|
||||
*/
|
||||
|
||||
/* ── B4 permission-mode relay (§3.5) ── */
|
||||
|
||||
/** --permission-mode values the relay exposes (R0-confirmed). 'auto' is its own
|
||||
* Claude Code mode (background safety checks), distinct from bypassPermissions
|
||||
* (the high-risk modes dontAsk/bypassPermissions are intentionally not surfaced).
|
||||
* Validated at the WS boundary; gated server-side by ALLOW_AUTO_MODE (SEC-M5). */
|
||||
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';
|
||||
|
||||
/** 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.
|
||||
* `token` is the per-decision capability token (only on needs-input). */
|
||||
export interface PushPayload {
|
||||
sessionId: string;
|
||||
toolName?: string;
|
||||
detail?: string;
|
||||
token?: string;
|
||||
cls: NotifyClass;
|
||||
}
|
||||
|
||||
/** A stored browser PushSubscription (persisted to a 600-perm JSON, never via GET). */
|
||||
export interface PushSubscriptionRecord {
|
||||
endpoint: string;
|
||||
keys: { p256dh: string; auth: string };
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
/** The notification provider, injected into the manager (DI, M5) so it never
|
||||
* imports push-service directly. isEnabled() is false when VAPID keys are unset
|
||||
* (graceful disable). notify() honors DND/NOTIFY_DONE internally. */
|
||||
export interface NotifyService {
|
||||
isEnabled(): boolean;
|
||||
notify(session: Session, cls: NotifyClass, token?: string): Promise<void>;
|
||||
}
|
||||
|
||||
/* ── B2 statusLine telemetry (§3.5, derived from R0 schema) ── */
|
||||
|
||||
/** Per-session telemetry derived server-side from the Claude Code statusLine
|
||||
* stdin JSON (R0). All metric fields are optional — parsing is tolerant (missing
|
||||
* field → undefined, never throw); `at` is the server receive time (Date.now()).
|
||||
* Field mapping (R0 → here): context_window.used_percentage→contextUsedPct,
|
||||
* cost.total_cost_usd→costUsd, cost.total_lines_added/removed→linesAdded/Removed,
|
||||
* model.display_name→model, effort.level→effort, pr→pr, rate_limits→rate. */
|
||||
export interface StatusTelemetry {
|
||||
contextUsedPct?: number;
|
||||
costUsd?: number;
|
||||
linesAdded?: number;
|
||||
linesRemoved?: number;
|
||||
model?: string;
|
||||
effort?: string;
|
||||
pr?: { number: number; url: string; reviewState?: string };
|
||||
rate?: { fiveHourPct?: number; sevenDayPct?: number };
|
||||
at: number;
|
||||
}
|
||||
|
||||
/* ── A4 activity timeline (§3.1) ── */
|
||||
|
||||
/** Server-DERIVED semantic class for a timeline event (review #1). The raw hook
|
||||
* name is mapped by timeline.ts deriveClass(); the FE consumes `class` directly
|
||||
* for icon/color and never re-derives from hook names. */
|
||||
export type TimelineClass = 'tool' | 'waiting' | 'done' | 'stuck' | 'user';
|
||||
|
||||
/** One bounded, timestamped activity entry (A4). `label` is the server-derived
|
||||
* human phrase ("ran Bash", "edited 3 files"); `toolName` is sanitized (≤200,
|
||||
* control chars stripped). `at` is the server Date.now() at ingest. */
|
||||
export interface TimelineEvent {
|
||||
at: number;
|
||||
class: TimelineClass;
|
||||
toolName?: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/* ── B1 read-only git diff (§3.2) ── */
|
||||
|
||||
/** ONE spelling (semantic words, review #2). Parsing lives ONLY in the backend
|
||||
* (src/http/diff.ts); public/diff.ts is render-only. */
|
||||
export type DiffLineKind = 'added' | 'removed' | 'context' | 'hunk' | 'meta';
|
||||
|
||||
export interface DiffLine {
|
||||
kind: DiffLineKind;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface DiffHunk {
|
||||
header: string;
|
||||
lines: DiffLine[];
|
||||
}
|
||||
|
||||
export type FileStatus =
|
||||
| 'modified'
|
||||
| 'added'
|
||||
| 'deleted'
|
||||
| 'renamed'
|
||||
| 'binary'
|
||||
| 'untracked';
|
||||
|
||||
export interface DiffFile {
|
||||
oldPath: string;
|
||||
newPath: string;
|
||||
status: FileStatus;
|
||||
added: number;
|
||||
removed: number;
|
||||
binary: boolean;
|
||||
hunks: DiffHunk[];
|
||||
}
|
||||
|
||||
export interface DiffResult {
|
||||
files: DiffFile[];
|
||||
staged: boolean;
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
/* ── B3 worktree creation (§3.5) ── */
|
||||
|
||||
/** Result of POST /projects/worktree (B3). `error` carries a safe message only
|
||||
* (never raw git stderr, SEC-M10); `status` is the HTTP status to return. */
|
||||
export interface CreateWorktreeResult {
|
||||
ok: boolean;
|
||||
path?: string;
|
||||
branch?: string;
|
||||
status?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/* ── GET /config/ui (review #4) ── */
|
||||
|
||||
/** Client-readable UI configuration (GET /config/ui). `allowAutoMode` mirrors
|
||||
* the server ALLOW_AUTO_MODE gate so the FE can hide the high-risk 'auto'
|
||||
* permission mode when the server forbids it (SEC-M5). */
|
||||
export interface UiConfig {
|
||||
allowAutoMode: boolean;
|
||||
}
|
||||
|
||||
/* ─────────────────────── frontend (§5/§6.3) ──────────────────── */
|
||||
|
||||
|
||||
Reference in New Issue
Block a user