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:
Yaojia Wang
2026-06-30 17:42:18 +02:00
parent 4f1d3ebc6b
commit d6809c65c4
54 changed files with 13171 additions and 200 deletions

View File

@@ -30,13 +30,18 @@ import type {
Config,
Dims,
LiveSessionInfo,
NotifyService,
PermissionGate,
ServerMessage,
Session,
SessionManager,
StatusTelemetry,
WebSocketLike,
} from '../types.js';
import { WS_OPEN } from '../types.js';
import { serialize } from '../protocol.js';
import { createSession, attachWs, broadcast, kill } from './session.js';
import { appendEvent, makeTimelineEvent } from './timeline.js';
import { hasSession, tmuxName } from './tmux.js';
/**
@@ -57,8 +62,16 @@ function sendIfOpen(ws: WebSocketLike | null, msg: Parameters<typeof serialize>[
* (add, delete) rather than in-place modification of the table structure.
* Individual Session objects do carry mutable fields (attachedWs, detachedAt,
* lastOutputAt, exitedAt, exitCode) by design — those are runtime handles.
*
* `notifyService` is injected (DI, M5) so the manager never imports push-service
* directly (avoids a manager↔push-service cycle). sweepStuck uses it to push the
* 'stuck' signal; when it is undefined (VAPID unset / tests) stuck detection still
* updates status + broadcasts, it just doesn't send a push.
*/
export function createSessionManager(cfg: Config): SessionManager {
export function createSessionManager(
cfg: Config,
notifyService?: NotifyService,
): SessionManager {
let sessions: Map<string, Session> = new Map();
/**
@@ -117,6 +130,14 @@ export function createSessionManager(cfg: Config): SessionManager {
// ── Case 2: hit a live session → JOIN it (multi-device sharing) ───────────
if (existing !== undefined && existing.exitedAt === null) {
attachWs(existing, ws);
// M3 / AC-B2.3: a late-joining device immediately gets the current
// telemetry (if any) and status so it doesn't wait for the next
// hook/statusLine report to refresh. The `pending`/`gate` portion of the
// status is re-sent by the server layer (it owns pendingApprovals).
if (existing.telemetry !== null) {
sendIfOpen(ws, { type: 'telemetry', telemetry: existing.telemetry });
}
sendIfOpen(ws, { type: 'status', status: existing.claudeStatus });
return existing;
}
@@ -168,6 +189,7 @@ export function createSessionManager(cfg: Config): SessionManager {
cwd: s.cwd,
cols: s.pty.cols,
rows: s.pty.rows,
telemetry: s.telemetry, // B2: latest telemetry for the thumbnail wall
}))
.sort((a, b) => b.createdAt - a.createdAt);
}
@@ -190,27 +212,77 @@ export function createSessionManager(cfg: Config): SessionManager {
}
/**
* H2: a Claude Code hook reported activity for `sessionId`. Record it and push
* a `status` frame to the attached ws (no-op if the session is gone/detached).
* H2/H3: a Claude Code hook reported activity for `sessionId`. Update the
* status, append a timeline event (A4) when `eventClass` is supplied and the
* timeline is enabled, and broadcast a `status` frame — carrying `gate` (B4)
* when the held approval is a 'tool'/'plan' gate. No-op for an unknown session.
*/
function handleHookEvent(
sessionId: string,
status: ClaudeStatus,
detail?: string,
pending?: boolean,
gate?: PermissionGate,
eventClass?: string,
toolName?: string,
): void {
const session = sessions.get(sessionId);
if (session === undefined) return;
session.claudeStatus = status;
const msg: { type: 'status'; status: ClaudeStatus; detail?: string; pending?: boolean } = {
type: 'status',
status,
};
// A4: derive a bounded, sanitized timeline event from the raw hook name.
// makeTimelineEvent whitelist-gates the event class (unknown → null = drop).
if (cfg.timelineEnabled && eventClass !== undefined) {
const ev = makeTimelineEvent(eventClass, toolName, Date.now());
if (ev !== null) {
session.timeline = appendEvent(session.timeline, ev, cfg.timelineMax);
}
}
const msg: Extract<ServerMessage, { type: 'status' }> = { type: 'status', status };
if (detail !== undefined) msg.detail = detail;
if (pending) msg.pending = true;
if (gate !== undefined) msg.gate = gate;
broadcast(session, msg);
}
/**
* B2: store the latest statusLine telemetry for a session and broadcast a
* `telemetry` frame to every attached client. No-op for an unknown session.
*/
function handleStatusLine(id: string, telemetry: StatusTelemetry): void {
const session = sessions.get(id);
if (session === undefined) return;
session.telemetry = telemetry;
broadcast(session, { type: 'telemetry', telemetry });
}
/**
* A5 (H4): on the existing reaper tick (no new timer), mark live, non-idle
* sessions whose output has been silent past STUCK_TTL as 'stuck'. Covers BOTH
* attached and detached living sessions. Sets the status, broadcasts it (§3.4
* review #5 — not just a notify), and pushes a single 'stuck' alert per silent
* round (re-armed by the next pty output in session.ts). Disabled when
* stuckAlert is off or stuckTtlMs ≤ 0.
*/
function sweepStuck(now: number): void {
if (!cfg.stuckAlert || cfg.stuckTtlMs <= 0) return;
for (const session of sessions.values()) {
if (session.exitedAt !== null) continue; // already exited
if (session.claudeStatus === 'idle') continue; // legitimately done
if (session.stuckNotified) continue; // already alerted this round
if (now - session.lastOutputAt <= cfg.stuckTtlMs) continue; // still fresh
session.claudeStatus = 'stuck';
session.stuckNotified = true;
broadcast(session, { type: 'status', status: 'stuck' });
void notifyService?.notify(session, 'stuck').catch((err: unknown) => {
console.error('[manager] stuck notification failed', err);
});
}
}
/**
* Reclaim detached sessions whose liveness window has expired (M3).
*
@@ -259,5 +331,15 @@ export function createSessionManager(cfg: Config): SessionManager {
sessions = new Map();
}
return { handleAttach, get, list, killById, handleHookEvent, reapIdle, shutdown };
return {
handleAttach,
get,
list,
killById,
handleHookEvent,
handleStatusLine,
sweepStuck,
reapIdle,
shutdown,
};
}

View File

@@ -32,6 +32,7 @@ import type {
Session,
SessionMeta,
ServerMessage,
TimelineEvent,
WebSocketLike,
} from '../types.js';
import { WS_OPEN } from '../types.js';
@@ -95,6 +96,9 @@ export function createSession(
...process.env,
WEBTERM_SESSION: id,
WEBTERM_HOOK_URL: `http://127.0.0.1:${cfg.port}/hook`,
// B2: statusLine scripts POST telemetry here; WEBTERM_NTFY_* vars forwarded
// via ...process.env spread above (H3 — token set in env, not here).
WEBTERM_STATUSLINE_URL: `http://127.0.0.1:${cfg.port}/hook/status`,
},
});
@@ -116,12 +120,25 @@ export function createSession(
cwd: cwd ?? null,
tmuxName: tName,
pty,
// 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
telemetry: null, // B2: updated by manager.handleStatusLine
};
// onData: persist to scrollback, refresh liveness, broadcast to all clients.
pty.onData((chunk) => {
session.buffer.append(chunk);
session.lastOutputAt = Date.now();
// A5 §3.4: re-arm the stuck flag on every output so each silent round can
// alert at most once. If we were stuck, recover to 'working' and broadcast
// so clients clear the stuck badge immediately — this is the ONLY reliable
// place to do this (same as lastOutputAt; T-manager never sees raw output).
session.stuckNotified = false;
if (session.claudeStatus === 'stuck') {
session.claudeStatus = 'working';
broadcast(session, { type: 'status', status: 'working' });
}
broadcast(session, { type: 'output', data: chunk });
});

143
src/session/timeline.ts Normal file
View File

@@ -0,0 +1,143 @@
/**
* src/session/timeline.ts — N-timeline (A4)
*
* Pure functions for the bounded, timestamped activity ring.
* Zero DOM dependencies; importable from both backend and tests.
*
* Security:
* SEC-H6 — sanitizeField strips control chars and truncates tool names/paths
* SEC-M8 — appendEvent enforces the ring cap on every call
*/
import type { TimelineClass, TimelineEvent } from '../types.js';
// ── constants ─────────────────────────────────────────────────────────────────
/** Tools whose PostToolUse event gets an "edited" flavour label. */
const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']);
/** Whitelisted Claude Code hook event names. Events outside this set are
* dropped in makeTimelineEvent (unknown hooks = don't pollute the timeline). */
const WHITELISTED_EVENTS = new Set([
'PreToolUse',
'PostToolUse',
'PermissionRequest',
'Notification',
'Stop',
'SessionEnd',
'UserPromptSubmit',
]);
/** Maps hook event names to semantic TimelineClass values (§3.1 review #1).
* The FE consumes `class` directly for icon/colour — never re-derives. */
const CLASS_MAP: Readonly<Record<string, TimelineClass>> = {
PreToolUse: 'tool',
PostToolUse: 'tool',
PermissionRequest: 'waiting',
Notification: 'waiting',
Stop: 'done',
SessionEnd: 'done',
UserPromptSubmit: 'user',
};
// ── sanitizeField ─────────────────────────────────────────────────────────────
/**
* Strip ASCII control characters (0x000x1f) from `s`, then truncate to `max`
* characters. Used for tool names and any other hook-supplied string fields.
* (SEC-H6)
*/
export function sanitizeField(s: string, max = 200): string {
// eslint-disable-next-line no-control-regex
return s.replace(/[\x00-\x1f]/g, '').slice(0, max);
}
// ── deriveClass ───────────────────────────────────────────────────────────────
/**
* Map a raw Claude Code hook event name to its semantic TimelineClass (§3.1).
* Falls back to 'tool' for any event that doesn't appear in the map —
* makeTimelineEvent already whitelist-gates, so this is just a safety net.
*/
export function deriveClass(eventName: string): TimelineClass {
return CLASS_MAP[eventName] ?? 'tool';
}
// ── deriveLabel ───────────────────────────────────────────────────────────────
/**
* Produce a human-language label for a hook event.
* `toolName` is expected to already be sanitized when called from
* makeTimelineEvent; callers must not pass raw hook data here directly.
*/
export function deriveLabel(eventName: string, toolName?: string): string {
switch (eventName) {
case 'PreToolUse':
return toolName ? `using ${toolName}` : 'using tool';
case 'PostToolUse':
if (toolName && EDIT_TOOLS.has(toolName)) {
return `edited with ${toolName}`;
}
return toolName ? `ran ${toolName}` : 'ran tool';
case 'PermissionRequest':
case 'Notification':
return 'waiting for approval';
case 'Stop':
case 'SessionEnd':
return 'done';
case 'UserPromptSubmit':
return 'user input';
default:
return 'activity';
}
}
// ── makeTimelineEvent ─────────────────────────────────────────────────────────
/**
* Build a TimelineEvent from raw hook data, or return null if the event name
* is not in the whitelist (unknown hooks are silently dropped).
*
* Sanitizes `toolName` via sanitizeField before embedding it in the event or
* passing it to deriveLabel (SEC-H6).
*/
export function makeTimelineEvent(
eventName: string,
toolName: string | undefined,
at: number,
): TimelineEvent | null {
if (!WHITELISTED_EVENTS.has(eventName)) return null;
const sanitizedToolName =
toolName !== undefined ? sanitizeField(toolName) : undefined;
return {
at,
class: deriveClass(eventName),
toolName: sanitizedToolName,
label: deriveLabel(eventName, sanitizedToolName),
};
}
// ── appendEvent ───────────────────────────────────────────────────────────────
/**
* Return a NEW array with `ev` appended to `events`, evicting the oldest
* entries if the length would exceed `maxLen`. Never mutates `events` (SEC-M8).
*/
export function appendEvent(
events: readonly TimelineEvent[],
ev: TimelineEvent,
maxLen: number,
): readonly TimelineEvent[] {
const next = [...events, ev];
if (next.length > maxLen) {
return next.slice(next.length - maxLen);
}
return next;
}