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:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
143
src/session/timeline.ts
Normal 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 (0x00–0x1f) 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;
|
||||
}
|
||||
Reference in New Issue
Block a user