From 7a581ab0be3ce883788e985325c174a37c002531 Mon Sep 17 00:00:00 2001 From: "Wit, Floris De (178)" Date: Thu, 9 Jul 2026 12:59:20 +0200 Subject: [PATCH 1/8] ci: add GitHub Actions build/typecheck/test workflow Node 20 + pinned pnpm via corepack. Runs install --frozen-lockfile, build (shared first), typecheck, and the keyless test suite (the live claude CLI test self-skips). Protects the session refactor that follows. --- .github/workflows/ci.yml | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..657ba3c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,32 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + build-and-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # Reads the pinned pnpm from package.json's "packageManager" field. + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + + # Builds native deps (better-sqlite3, esbuild) per the onlyBuiltDependencies allowlist. + - run: pnpm install --frozen-lockfile + + # shared must build before server/web typecheck (they consume its dist/). + - run: pnpm build + + - run: pnpm typecheck + + # Keyless: the live claude CLI test self-skips unless CLAUDE_LIVE=1 (never set here). + - run: pnpm test From 4e5bf80e781e832aa20c819303091e1137e11447 Mon Sep 17 00:00:00 2001 From: "Wit, Floris De (178)" Date: Thu, 9 Jul 2026 13:16:16 +0200 Subject: [PATCH 2/8] feat(sessions): CLI-parity config + unify approval dial with --permission-mode A hub session now behaves like a native `claude` session started in the same directory. Per-session config passes through to the CLI and inherits ~/.claude when unset: - permissionMode -> --permission-mode (canonical control) - model -> --model - settings -> --settings The legacy 3-way approvalMode dial becomes a preset alias that maps onto the CLI's native modes (approve_all->manual, approve_writes->acceptEdits, full_auto->bypassPermissions), and the UI exposes the CLI modes directly (incl. plan/dontAsk/auto) with an 'inherit' default. Permission policy is delegated to the engine: the CLI applies --permission-mode and only routes tools it wants asked to --permission-prompt-tool; the hub relays those to the user. This retires the hub's READ_ONLY_TOOLS tool-name policy (and its spoofing sharp edge) and the ApprovalBroker. The echo stub now applies the mode itself (permissionModeDisposition) to stay a faithful template. DB migration 2 adds permission_mode/requested_model/settings and backfills permission_mode from the legacy approval_mode preset. Verified live against claude 2.1.205: manual asks for Write, bypassPermissions runs it silently, --model sonnet reaches the CLI; migration applied cleanly to existing sessions. --- server/src/approvals/ApprovalBroker.ts | 34 ------------- server/src/approvals/permissionMode.ts | 51 +++++++++++++++++++ server/src/db/database.ts | 18 +++++++ server/src/db/repos.ts | 35 +++++++++---- .../src/engines/claude/ClaudeCodeAdapter.ts | 6 ++- server/src/engines/echo/EchoAdapter.ts | 35 ++++++++++--- server/src/engines/types.ts | 16 ++++-- server/src/sessions/SessionManager.ts | 15 +++++- server/src/sessions/SessionRuntime.ts | 42 +++++++-------- server/test/adapter-contract.ts | 8 +-- server/test/broker.test.ts | 23 --------- server/test/claude-live.test.ts | 4 +- server/test/e2e.test.ts | 17 ++++--- server/test/permissionMode.test.ts | 32 ++++++++++++ shared/src/api.ts | 21 +++++++- shared/src/events.ts | 20 +++++++- web/src/components/NewSessionDialog.svelte | 34 ++++++++++--- web/src/components/SessionSidebar.svelte | 4 +- web/src/components/StatusBar.svelte | 23 +++++---- web/src/lib/format.ts | 33 +++++++++--- 20 files changed, 324 insertions(+), 147 deletions(-) delete mode 100644 server/src/approvals/ApprovalBroker.ts create mode 100644 server/src/approvals/permissionMode.ts delete mode 100644 server/test/broker.test.ts create mode 100644 server/test/permissionMode.test.ts diff --git a/server/src/approvals/ApprovalBroker.ts b/server/src/approvals/ApprovalBroker.ts deleted file mode 100644 index e17f143..0000000 --- a/server/src/approvals/ApprovalBroker.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { ApprovalMode } from '@hub/shared'; - -/** - * Policy for tool approvals. The adapter surfaces every permission request; - * this broker decides whether it can be auto-allowed or must ask the user. - * Denials only ever come from the user. - */ - -const READ_ONLY_TOOLS = new Set([ - 'Read', - 'Glob', - 'Grep', - 'WebFetch', - 'WebSearch', - 'TodoWrite', - 'Task', - 'NotebookRead', - 'ListMcpResourcesTool', - 'ReadMcpResourceTool', - 'echo_probe_readonly', // used by tests -]); - -export type ApprovalDecision = 'allow' | 'ask_user'; - -export function decideApproval(mode: ApprovalMode, toolName: string): ApprovalDecision { - switch (mode) { - case 'full_auto': - return 'allow'; - case 'approve_writes': - return READ_ONLY_TOOLS.has(toolName) ? 'allow' : 'ask_user'; - case 'approve_all': - return 'ask_user'; - } -} diff --git a/server/src/approvals/permissionMode.ts b/server/src/approvals/permissionMode.ts new file mode 100644 index 0000000..cae5ace --- /dev/null +++ b/server/src/approvals/permissionMode.ts @@ -0,0 +1,51 @@ +import type { ApprovalMode, PermissionMode } from '@hub/shared'; + +/** + * Permission policy is delegated to the engine. The Claude CLI applies + * `--permission-mode` natively and only routes tools it wants asked to the + * `--permission-prompt-tool`; the hub simply relays those to the user. This + * module holds the two pure helpers that support that model: + * + * - presetToPermissionMode: map the legacy 3-way UI dial onto the CLI's modes. + * - permissionModeDisposition: how a mode treats a not-pre-allowed tool, for + * engines that must emulate the mode themselves (the echo stub). + * + * The old tool-name policy (READ_ONLY_TOOLS) is gone: it trusted tool *names* + * and could be spoofed, and the CLI already does the write/read distinction + * correctly via `acceptEdits`. + */ + +export function presetToPermissionMode(preset: ApprovalMode): PermissionMode { + switch (preset) { + case 'approve_all': + return 'manual'; + case 'approve_writes': + return 'acceptEdits'; + case 'full_auto': + return 'bypassPermissions'; + } +} + +export type PermissionDisposition = 'ask' | 'allow' | 'deny'; + +/** + * What a mode does with a tool that is NOT pre-allowed by settings. Mirrors the + * Claude CLI so the echo stub stays a faithful template: + * - manual / acceptEdits / plan / null -> ask via the permission prompt + * (acceptEdits auto-allows only *edit* tools, so a non-edit tool still asks) + * - auto / bypassPermissions -> allow without asking + * - dontAsk -> deny unless allowlisted + */ +export function permissionModeDisposition( + mode: PermissionMode | null | undefined, +): PermissionDisposition { + switch (mode) { + case 'auto': + case 'bypassPermissions': + return 'allow'; + case 'dontAsk': + return 'deny'; + default: + return 'ask'; + } +} diff --git a/server/src/db/database.ts b/server/src/db/database.ts index 4880764..a7cfbd5 100644 --- a/server/src/db/database.ts +++ b/server/src/db/database.ts @@ -37,6 +37,24 @@ CREATE TABLE events ( payload TEXT NOT NULL ); CREATE INDEX idx_events_session ON events(session_id, id); +`, + }, + { + // Unify the permission dial with the CLI's --permission-mode, and add the + // per-session config passthroughs (requested model, settings). Backfill the + // new permission_mode from the legacy approval_mode preset. + id: 2, + sql: ` +ALTER TABLE sessions ADD COLUMN permission_mode TEXT; +ALTER TABLE sessions ADD COLUMN requested_model TEXT; +ALTER TABLE sessions ADD COLUMN settings TEXT; + +UPDATE sessions SET permission_mode = CASE approval_mode + WHEN 'approve_all' THEN 'manual' + WHEN 'approve_writes' THEN 'acceptEdits' + WHEN 'full_auto' THEN 'bypassPermissions' + ELSE NULL +END; `, }, ]; diff --git a/server/src/db/repos.ts b/server/src/db/repos.ts index ba3ee65..67aa2c3 100644 --- a/server/src/db/repos.ts +++ b/server/src/db/repos.ts @@ -1,7 +1,7 @@ import type Database from 'better-sqlite3'; import type { - ApprovalMode, NormalizedEvent, + PermissionMode, SessionDto, SessionEvent, SessionStatus, @@ -13,7 +13,7 @@ interface SessionRow { engine_id: string; engine_session_id: string | null; cwd: string; - approval_mode: string; + permission_mode: string | null; status: string; pid: number | null; created_at: number; @@ -21,6 +21,8 @@ interface SessionRow { archived_at: number | null; last_cost_usd: number | null; model: string | null; + requested_model: string | null; + settings: string | null; } function toDto(r: SessionRow): SessionDto { @@ -30,13 +32,15 @@ function toDto(r: SessionRow): SessionDto { engineId: r.engine_id, engineSessionId: r.engine_session_id, cwd: r.cwd, - approvalMode: r.approval_mode as ApprovalMode, + permissionMode: (r.permission_mode as PermissionMode | null) ?? null, status: r.status as SessionStatus, createdAt: r.created_at, updatedAt: r.updated_at, archivedAt: r.archived_at, lastCostUsd: r.last_cost_usd, model: r.model, + requestedModel: r.requested_model, + settings: r.settings, }; } @@ -48,15 +52,18 @@ export class SessionsRepo { title: string; engineId: string; cwd: string; - approvalMode: ApprovalMode; + permissionMode: PermissionMode | null; + model: string | null; + settings: string | null; }): SessionDto { const now = Date.now(); this.db .prepare( - `INSERT INTO sessions (id, title, engine_id, cwd, approval_mode, status, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, 'idle', ?, ?)`, + `INSERT INTO sessions + (id, title, engine_id, cwd, permission_mode, requested_model, settings, status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, 'idle', ?, ?)`, ) - .run(s.id, s.title, s.engineId, s.cwd, s.approvalMode, now, now); + .run(s.id, s.title, s.engineId, s.cwd, s.permissionMode, s.model, s.settings, now, now); return this.get(s.id)!; } @@ -83,8 +90,18 @@ export class SessionsRepo { this.touch(id); } - setApprovalMode(id: string, mode: ApprovalMode): void { - this.db.prepare('UPDATE sessions SET approval_mode = ? WHERE id = ?').run(mode, id); + setPermissionMode(id: string, mode: PermissionMode | null): void { + this.db.prepare('UPDATE sessions SET permission_mode = ? WHERE id = ?').run(mode, id); + this.touch(id); + } + + setRequestedModel(id: string, model: string | null): void { + this.db.prepare('UPDATE sessions SET requested_model = ? WHERE id = ?').run(model, id); + this.touch(id); + } + + setSettings(id: string, settings: string | null): void { + this.db.prepare('UPDATE sessions SET settings = ? WHERE id = ?').run(settings, id); this.touch(id); } diff --git a/server/src/engines/claude/ClaudeCodeAdapter.ts b/server/src/engines/claude/ClaudeCodeAdapter.ts index 234af95..bf210ab 100644 --- a/server/src/engines/claude/ClaudeCodeAdapter.ts +++ b/server/src/engines/claude/ClaudeCodeAdapter.ts @@ -72,8 +72,12 @@ class ClaudeRun implements EngineRun { '--output-format', 'stream-json', '--verbose', '--permission-prompt-tool', 'stdio', - ...extraArgs, ]; + // Per-session config passthrough; each unset value inherits the ~/.claude default. + if (opts.permissionMode) args.push('--permission-mode', opts.permissionMode); + if (opts.model) args.push('--model', opts.model); + if (opts.settings) args.push('--settings', opts.settings); + args.push(...extraArgs); if (opts.resumeEngineSessionId) args.push('--resume', opts.resumeEngineSessionId); const env: NodeJS.ProcessEnv = { ...process.env, ...opts.env }; diff --git a/server/src/engines/echo/EchoAdapter.ts b/server/src/engines/echo/EchoAdapter.ts index 1bcda57..c5ccc35 100644 --- a/server/src/engines/echo/EchoAdapter.ts +++ b/server/src/engines/echo/EchoAdapter.ts @@ -1,11 +1,13 @@ import { randomUUID } from 'node:crypto'; import type { EngineDescriptor, NormalizedEvent } from '@hub/shared'; +import { permissionModeDisposition } from '../../approvals/permissionMode.js'; import type { EngineAdapter, EngineRun, StartOptions } from '../types.js'; /** * In-process stub engine. Proves the adapter contract and powers keyless - * end-to-end tests: every user message triggers a tool call (which flows - * through the approval broker like a real one), a tool result, a streamed + * end-to-end tests: every user message triggers a tool call, then (depending on + * the session's permission mode) either an approval request the hub relays to + * the user or a silent auto-allow/deny, followed by a tool result, a streamed * markdown message, and a done event. */ export class EchoAdapter implements EngineAdapter { @@ -72,13 +74,21 @@ class EchoRun implements EngineRun { } const callId = `echo-call-${randomUUID()}`; const requestId = `echo-req-${randomUUID()}`; + // Emulate a real engine applying its own permission mode: only surface an + // approval when the mode wants the user asked. auto/bypass proceed silently; + // dontAsk auto-denies. The hub never decides policy. + const disposition = permissionModeDisposition(this.opts.permissionMode); this.later(10, () => { this.emit({ type: 'tool_call', callId, toolName: 'echo_probe', input: { text } }); - // Ask for approval exactly like a real engine would; the runtime's - // broker decides whether the user actually sees it. - this.pendingApproval = { requestId, text }; - this.approvalCallId.set(requestId, callId); - this.emit({ type: 'approval_request', requestId, toolName: 'echo_probe', input: { text } }); + if (disposition === 'ask') { + this.pendingApproval = { requestId, text }; + this.approvalCallId.set(requestId, callId); + this.emit({ type: 'approval_request', requestId, toolName: 'echo_probe', input: { text } }); + } else if (disposition === 'deny') { + this.finishTurn(callId, text, 'deny', 'Denied by permission mode'); + } else { + this.finishTurn(callId, text, 'allow'); + } }); } @@ -90,13 +100,22 @@ class EchoRun implements EngineRun { this.pendingApproval = null; const callId = this.approvalCallId.get(requestId) ?? 'unknown'; this.approvalCallId.delete(requestId); + this.finishTurn(callId, text, behavior, opts?.message); + } + /** Emit the tool result + assistant reply + done for an allowed or denied tool. */ + private finishTurn( + callId: string, + text: string, + behavior: 'allow' | 'deny', + message?: string, + ): void { if (behavior === 'deny') { this.later(10, () => { this.emit({ type: 'tool_result', callId, - output: `Denied by user${opts?.message ? `: ${opts.message}` : ''}`, + output: `Denied${message ? `: ${message}` : ''}`, isError: true, }); this.emit({ diff --git a/server/src/engines/types.ts b/server/src/engines/types.ts index 0db0d0b..9a1f90d 100644 --- a/server/src/engines/types.ts +++ b/server/src/engines/types.ts @@ -1,11 +1,16 @@ -import type { ApprovalMode, EngineDescriptor, NormalizedEvent } from '@hub/shared'; +import type { EngineDescriptor, NormalizedEvent, PermissionMode } from '@hub/shared'; export interface StartOptions { /** Platform session id (not the engine-native one). */ sessionId: string; /** Validated absolute working directory. */ cwd: string; - approvalMode: ApprovalMode; + /** Passed to the CLI as --permission-mode; null/undefined = inherit default. */ + permissionMode?: PermissionMode | null; + /** Passed to the CLI as --model; null/undefined = inherit default. */ + model?: string | null; + /** Passed to the CLI as --settings (file path or inline JSON); null = inherit. */ + settings?: string | null; /** Engine-native session id to resume, if any. */ resumeEngineSessionId?: string; env?: Record; @@ -15,9 +20,10 @@ export interface StartOptions { * A live engine run bound to one session. Implementations own the child * process (if any) and translate its native stream into NormalizedEvents. * - * Approval contract: the adapter emits `approval_request` events and then - * blocks the tool until respondToApproval() is called — it does NOT decide - * policy. Policy lives in the ApprovalBroker. + * Approval contract: the adapter emits `approval_request` events only for tools + * its permission mode wants asked, then blocks the tool until respondToApproval() + * is called. The hub relays every surfaced request to the user; auto-allow/deny + * is decided by the engine's permission mode, not by the hub. */ export interface EngineRun { readonly pid?: number; diff --git a/server/src/sessions/SessionManager.ts b/server/src/sessions/SessionManager.ts index e0fbfeb..013d4e2 100644 --- a/server/src/sessions/SessionManager.ts +++ b/server/src/sessions/SessionManager.ts @@ -6,6 +6,7 @@ import type { HubBus } from '../bus.js'; import type { HubConfig } from '../config.js'; import type { EventsRepo, SessionsRepo } from '../db/repos.js'; import type { EngineRegistry } from '../engines/registry.js'; +import { presetToPermissionMode } from '../approvals/permissionMode.js'; import { validateCwd } from '../fs/workspace.js'; import { HubError, SessionRuntime } from './SessionRuntime.js'; @@ -71,12 +72,18 @@ export class SessionManager { if (!cwdCheck.ok || !cwdCheck.resolved) { throw new HubError('invalid_cwd', cwdCheck.error ?? 'Invalid working directory'); } + // permissionMode is canonical; the legacy approvalMode preset is a fallback. + // Unset => null => the CLI/`~/.claude` default (which is `manual`). + const permissionMode = + req.permissionMode ?? (req.approvalMode ? presetToPermissionMode(req.approvalMode) : null); const dto = this.sessions.create({ id: randomUUID(), title: req.title?.trim() || `${path.basename(cwdCheck.resolved)} · ${engine.name}`, engineId: req.engineId, cwd: cwdCheck.resolved, - approvalMode: req.approvalMode ?? 'approve_all', + permissionMode, + model: req.model ?? null, + settings: req.settings ?? null, }); this.bus.emit('session_upsert', dto); return dto; @@ -94,7 +101,11 @@ export class SessionManager { const existing = this.sessions.get(id); if (!existing) throw new HubError('not_found', 'Session not found'); if (req.title !== undefined) this.sessions.setTitle(id, req.title.trim() || existing.title); - if (req.approvalMode !== undefined) this.sessions.setApprovalMode(id, req.approvalMode); + if (req.permissionMode !== undefined) this.sessions.setPermissionMode(id, req.permissionMode); + else if (req.approvalMode !== undefined) + this.sessions.setPermissionMode(id, presetToPermissionMode(req.approvalMode)); + if (req.model !== undefined) this.sessions.setRequestedModel(id, req.model); + if (req.settings !== undefined) this.sessions.setSettings(id, req.settings); if (req.archived !== undefined) this.sessions.setArchived(id, req.archived); this.runtimes.get(id)?.refresh(); const dto = this.sessions.get(id)!; diff --git a/server/src/sessions/SessionRuntime.ts b/server/src/sessions/SessionRuntime.ts index e0d9ea2..cb21135 100644 --- a/server/src/sessions/SessionRuntime.ts +++ b/server/src/sessions/SessionRuntime.ts @@ -3,14 +3,13 @@ import type { NormalizedEvent, SessionDto, SessionStatus } from '@hub/shared'; import type { HubBus } from '../bus.js'; import type { HubConfig } from '../config.js'; import type { EventsRepo, SessionsRepo } from '../db/repos.js'; -import { decideApproval } from '../approvals/ApprovalBroker.js'; import type { EngineAdapter, EngineRun } from '../engines/types.js'; /** * Owns the live engine run for one session: lazily starts the engine on the - * first message (resuming the engine-native session where available), routes - * approvals through policy, persists every complete event, and fans out to - * the WebSocket layer via the bus. + * first message (resuming the engine-native session where available), relays + * engine-surfaced approvals to the user, persists every complete event, and + * fans out to the WebSocket layer via the bus. * * State machine: idle -> running <-> awaiting_approval -> idle | error. * Only one in-flight turn per session. @@ -58,7 +57,9 @@ export class SessionRuntime { const run = this.adapter.start({ sessionId: this.session.id, cwd: this.session.cwd, - approvalMode: this.session.approvalMode, + permissionMode: this.session.permissionMode, + model: this.session.requestedModel, + settings: this.session.settings, resumeEngineSessionId: this.session.engineSessionId ?? undefined, }); this.run = run; @@ -88,26 +89,17 @@ export class SessionRuntime { return; } case 'approval_request': { - const decision = decideApproval(this.session.approvalMode, e.toolName); + // The engine only surfaces tools its permission mode wants asked, so the + // hub always relays the request to the user (no hub-side auto-allow). this.persist(e); - if (decision === 'allow') { - this.persist({ - type: 'approval_resolved', - requestId: e.requestId, - behavior: 'allow', - resolvedBy: 'policy', - }); - this.run?.respondToApproval(e.requestId, 'allow'); - } else { - this.pendingUserApprovals.add(e.requestId); - this.setStatus('awaiting_approval'); - if (this.config.approvalTimeoutMs > 0) { - const t = setTimeout( - () => this.respondToApproval(e.requestId, 'deny', 'Timed out waiting for approval'), - this.config.approvalTimeoutMs, - ); - this.approvalTimers.set(e.requestId, t); - } + this.pendingUserApprovals.add(e.requestId); + this.setStatus('awaiting_approval'); + if (this.config.approvalTimeoutMs > 0) { + const t = setTimeout( + () => this.respondToApproval(e.requestId, 'deny', 'Timed out waiting for approval'), + this.config.approvalTimeoutMs, + ); + this.approvalTimers.set(e.requestId, t); } return; } @@ -192,7 +184,7 @@ export class SessionRuntime { await this.run.interrupt(); } - /** Refresh cached session row (e.g. after approval-mode change). */ + /** Refresh cached session row (e.g. after permission-mode change). */ refresh(): void { const dto = this.sessions.get(this.session.id); if (dto) this.session = dto; diff --git a/server/test/adapter-contract.ts b/server/test/adapter-contract.ts index aaf37ca..3f63e7e 100644 --- a/server/test/adapter-contract.ts +++ b/server/test/adapter-contract.ts @@ -17,13 +17,13 @@ export function adapterContract(makeAdapter: () => EngineAdapter, opts: { cwd: s const run = makeAdapter().start({ sessionId: 's1', cwd: opts.cwd, - approvalMode: 'full_auto', + permissionMode: 'manual', }); const events: NormalizedEvent[] = []; const done = new Promise((resolve) => { run.onEvent((e) => { events.push(e); - // Contract consumers (SessionRuntime) auto-allow via policy; emulate. + // The hub relays surfaced approvals to the user; emulate the user allowing. if (e.type === 'approval_request') run.respondToApproval(e.requestId, 'allow'); if (e.type === 'done') resolve(); }); @@ -53,7 +53,7 @@ export function adapterContract(makeAdapter: () => EngineAdapter, opts: { cwd: s const run = makeAdapter().start({ sessionId: 's2', cwd: opts.cwd, - approvalMode: 'approve_all', + permissionMode: 'manual', }); const events: NormalizedEvent[] = []; const done = new Promise((resolve) => { @@ -77,7 +77,7 @@ export function adapterContract(makeAdapter: () => EngineAdapter, opts: { cwd: s const run = makeAdapter().start({ sessionId: 's3', cwd: opts.cwd, - approvalMode: 'approve_all', + permissionMode: 'manual', }); const events: NormalizedEvent[] = []; const done = new Promise((resolve) => { diff --git a/server/test/broker.test.ts b/server/test/broker.test.ts deleted file mode 100644 index bbd6a0c..0000000 --- a/server/test/broker.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { decideApproval } from '../src/approvals/ApprovalBroker.js'; - -describe('decideApproval', () => { - it('approve_all asks for everything', () => { - expect(decideApproval('approve_all', 'Read')).toBe('ask_user'); - expect(decideApproval('approve_all', 'Bash')).toBe('ask_user'); - }); - - it('approve_writes auto-allows read-only tools, asks for the rest', () => { - expect(decideApproval('approve_writes', 'Read')).toBe('allow'); - expect(decideApproval('approve_writes', 'Grep')).toBe('allow'); - expect(decideApproval('approve_writes', 'Bash')).toBe('ask_user'); - expect(decideApproval('approve_writes', 'Write')).toBe('ask_user'); - expect(decideApproval('approve_writes', 'Edit')).toBe('ask_user'); - expect(decideApproval('approve_writes', 'mcp__github__merge_pull_request')).toBe('ask_user'); - }); - - it('full_auto allows everything', () => { - expect(decideApproval('full_auto', 'Bash')).toBe('allow'); - expect(decideApproval('full_auto', 'Write')).toBe('allow'); - }); -}); diff --git a/server/test/claude-live.test.ts b/server/test/claude-live.test.ts index 9bc6c73..5b59736 100644 --- a/server/test/claude-live.test.ts +++ b/server/test/claude-live.test.ts @@ -30,7 +30,7 @@ live('ClaudeCodeAdapter (live CLI)', () => { }); it('write-file turn with approval round trip; resume recalls context', async () => { - const run = adapter.start({ sessionId: 'live-1', cwd: workspace, approvalMode: 'approve_all' }); + const run = adapter.start({ sessionId: 'live-1', cwd: workspace, permissionMode: 'manual' }); const events: NormalizedEvent[] = []; let engineSessionId: string | undefined; @@ -60,7 +60,7 @@ live('ClaudeCodeAdapter (live CLI)', () => { const run2 = adapter.start({ sessionId: 'live-1', cwd: workspace, - approvalMode: 'approve_all', + permissionMode: 'manual', resumeEngineSessionId: engineSessionId, }); const texts: string[] = []; diff --git a/server/test/e2e.test.ts b/server/test/e2e.test.ts index 8f0ae74..03cede8 100644 --- a/server/test/e2e.test.ts +++ b/server/test/e2e.test.ts @@ -249,7 +249,7 @@ describe('end-to-end with echo engine', () => { await server.close(); }, 30_000); - it('full_auto mode never surfaces an approval to the user', async () => { + it('full_auto (bypassPermissions) runs tools without surfacing any approval', async () => { server = await buildServer(testConfig(dataDir, workspace)); const port = server.address.port; const c = new Client(port); @@ -257,11 +257,13 @@ describe('end-to-end with echo engine', () => { c.send({ type: 'auth', token: TOKEN }); await c.next((m) => m.type === 'auth_ok'); + // Legacy preset alias: full_auto maps to the CLI's bypassPermissions. const session = await api(port, 'POST', '/api/sessions', { engineId: 'echo', cwd: workspace, approvalMode: 'full_auto', }); + expect(session.permissionMode).toBe('bypassPermissions'); c.send({ type: 'subscribe', sessionId: session.id }); c.send({ type: 'user_message', sessionId: session.id, text: 'yolo' }); await c.next( @@ -269,14 +271,17 @@ describe('end-to-end with echo engine', () => { 10_000, ); - // No awaiting_approval status ever, and the approval resolved by policy. + // The engine applies the mode itself, so nothing is asked: no awaiting_approval + // status and no approval events at all, yet the tool still ran. expect( c.received.some((m) => m.type === 'status' && m.status === 'awaiting_approval'), ).toBe(false); - const resolved = c - .events(session.id) - .find((e) => e.event.type === 'approval_resolved'); - expect((resolved!.event as { resolvedBy: string }).resolvedBy).toBe('policy'); + const types = c.events(session.id).map((e) => e.event.type); + expect(types).not.toContain('approval_request'); + expect(types).not.toContain('approval_resolved'); + expect(types).toContain('tool_call'); + expect(types).toContain('tool_result'); + expect(types[types.length - 1]).toBe('done'); c.close(); await server.close(); diff --git a/server/test/permissionMode.test.ts b/server/test/permissionMode.test.ts new file mode 100644 index 0000000..1439304 --- /dev/null +++ b/server/test/permissionMode.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { + permissionModeDisposition, + presetToPermissionMode, +} from '../src/approvals/permissionMode.js'; + +describe('presetToPermissionMode', () => { + it('maps the legacy 3-way dial onto CLI permission modes', () => { + expect(presetToPermissionMode('approve_all')).toBe('manual'); + expect(presetToPermissionMode('approve_writes')).toBe('acceptEdits'); + expect(presetToPermissionMode('full_auto')).toBe('bypassPermissions'); + }); +}); + +describe('permissionModeDisposition', () => { + it('asks for modes that prompt (and the inherited default)', () => { + expect(permissionModeDisposition('manual')).toBe('ask'); + expect(permissionModeDisposition('acceptEdits')).toBe('ask'); // non-edit tool still asks + expect(permissionModeDisposition('plan')).toBe('ask'); + expect(permissionModeDisposition(null)).toBe('ask'); + expect(permissionModeDisposition(undefined)).toBe('ask'); + }); + + it('allows for auto-approving modes', () => { + expect(permissionModeDisposition('auto')).toBe('allow'); + expect(permissionModeDisposition('bypassPermissions')).toBe('allow'); + }); + + it('denies for dontAsk (auto-deny unless allowlisted)', () => { + expect(permissionModeDisposition('dontAsk')).toBe('deny'); + }); +}); diff --git a/shared/src/api.ts b/shared/src/api.ts index 082f8ba..01d2a26 100644 --- a/shared/src/api.ts +++ b/shared/src/api.ts @@ -1,4 +1,4 @@ -import type { ApprovalMode, SessionStatus } from './events.js'; +import type { ApprovalMode, PermissionMode, SessionStatus } from './events.js'; export interface EngineDescriptor { id: string; @@ -18,25 +18,42 @@ export interface SessionDto { engineId: string; engineSessionId: string | null; cwd: string; - approvalMode: ApprovalMode; + /** Canonical permission control, passed to the CLI as --permission-mode. + * null = inherit the CLI / ~/.claude default. */ + permissionMode: PermissionMode | null; status: SessionStatus; createdAt: number; updatedAt: number; archivedAt: number | null; lastCostUsd: number | null; + /** Model the engine actually reported using (from engine_meta). */ model: string | null; + /** User-requested model override, passed to the CLI as --model (null = inherit). */ + requestedModel: string | null; + /** Optional --settings override (file path or inline JSON); null = inherit. */ + settings: string | null; } export interface CreateSessionRequest { title?: string; engineId: string; cwd: string; + /** Canonical control. When omitted, `approvalMode` (legacy preset) is used. */ + permissionMode?: PermissionMode; + /** Legacy preset alias; mapped to permissionMode when permissionMode is unset. */ approvalMode?: ApprovalMode; + model?: string; + settings?: string; } export interface UpdateSessionRequest { title?: string; + /** null resets to inherit the ~/.claude default. */ + permissionMode?: PermissionMode | null; + /** Legacy preset alias; mapped to permissionMode. */ approvalMode?: ApprovalMode; + model?: string; + settings?: string; archived?: boolean; } diff --git a/shared/src/events.ts b/shared/src/events.ts index 974c461..30ff35a 100644 --- a/shared/src/events.ts +++ b/shared/src/events.ts @@ -1,6 +1,24 @@ -/** Per-session approval policy: the "safety dial". */ +/** + * Legacy UI presets for the "safety dial". Kept as friendly aliases that map + * onto the CLI's native PermissionMode (see presetToPermissionMode); the + * canonical per-session control is `permissionMode`. + */ export type ApprovalMode = 'approve_all' | 'approve_writes' | 'full_auto'; +/** + * The Claude CLI's own `--permission-mode` values (2.1.x). This is the canonical + * per-session permission control: it passes straight through to the CLI, so a + * hub session behaves exactly like a terminal session started with that flag. + * `null`/unset means "inherit the CLI/`~/.claude` default" (which is `manual`). + */ +export type PermissionMode = + | 'manual' // = the CLI's `default`: ask for anything not pre-allowed + | 'acceptEdits' // auto-allow edits + safe fs commands, ask for the rest + | 'plan' // read-only exploration, edits blocked, still prompts + | 'auto' // auto-approve with background safety checks (research preview) + | 'dontAsk' // auto-deny unless pre-allowed by settings rules + | 'bypassPermissions'; // skip prompts (except explicit ask rules / circuit breakers) + export type SessionStatus = | 'idle' | 'starting' diff --git a/web/src/components/NewSessionDialog.svelte b/web/src/components/NewSessionDialog.svelte index b80a03f..ff27548 100644 --- a/web/src/components/NewSessionDialog.svelte +++ b/web/src/components/NewSessionDialog.svelte @@ -1,8 +1,13 @@ -
+
{status.replace('_', ' ')} {session.cwd} @@ -29,9 +30,13 @@ {#if formatCost(session.lastCostUsd)} {formatCost(session.lastCostUsd)} {/if} - + {#each PERMISSION_MODES as mode (mode.value ?? 'inherit')} + {/each}
diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts index f6c723d..335c06e 100644 --- a/web/src/lib/format.ts +++ b/web/src/lib/format.ts @@ -30,9 +30,30 @@ export function summarizeInput(toolName: string, input: unknown): string { return s.length > 80 ? s.slice(0, 77) + '…' : s; } -/** Approval modes in dial order with display labels. */ -export const APPROVAL_MODES = [ - { value: 'approve_all', label: 'Approve every tool', short: 'approve all' }, - { value: 'approve_writes', label: 'Approve writes only', short: 'writes only' }, - { value: 'full_auto', label: 'Full auto (YOLO)', short: 'FULL AUTO' }, -] as const; +import type { PermissionMode } from '@hub/shared'; + +/** + * Permission modes in dial order with display labels. These are the Claude CLI's + * own `--permission-mode` values; `value: null` means inherit the directory's + * ~/.claude default. `danger` flags the modes that reduce prompting. + */ +export const PERMISSION_MODES: { + value: PermissionMode | null; + label: string; + short: string; + hint: string; + danger?: boolean; +}[] = [ + { value: null, label: 'Inherit (~/.claude)', short: 'inherit', hint: "Use the directory's own Claude settings (CLI default)" }, + { value: 'manual', label: 'Ask for every tool', short: 'ask all', hint: 'Prompt before any tool not pre-approved' }, + { value: 'acceptEdits', label: 'Auto-accept edits', short: 'edits ok', hint: 'Allow edits + safe fs commands, ask for the rest' }, + { value: 'plan', label: 'Plan (read-only)', short: 'plan', hint: 'Explore and read only; edits blocked' }, + { value: 'dontAsk', label: 'Deny unless allowlisted', short: 'deny', hint: 'Auto-deny tools not pre-approved in settings' }, + { value: 'auto', label: 'Auto (preview)', short: 'auto', hint: 'Auto-approve with background safety checks' }, + { value: 'bypassPermissions', label: 'Full auto (bypass)', short: 'BYPASS', hint: 'Skip prompts entirely', danger: true }, +]; + +/** The mode that gets the red "danger" treatment in the UI. */ +export function isDangerMode(mode: PermissionMode | null): boolean { + return mode === 'bypassPermissions'; +} From c9c21aef2f6f330b4e22c17f96d5b607aa563379 Mon Sep 17 00:00:00 2001 From: "Wit, Floris De (178)" Date: Thu, 9 Jul 2026 13:30:34 +0200 Subject: [PATCH 3/8] feat(sessions): surface engine liveness + stop-engine control Add a second state axis to SessionDto, independent of the interaction status: - engineState: new | live | hibernated | dead - enginePid: the live engine's OS pid (or null) SessionRuntime tracks transitions: live on spawn, hibernated on idle-dispose, stop, or boot reconcile, dead on an unexpected exit. A mid-turn crash also closes the open turn with a done{error} instead of hanging in 'running'. New stop_engine WS control tears the process down now while keeping the session resumable (restart happens on the next message via --resume). Migration 3 adds engine_state. UI: a per-session liveness dot in the sidebar, an engine indicator + 'stop engine' button in the status bar, and the permission dial shows the CLI modes. Verified live against claude 2.1.205: live->dead on external SIGKILL (idle and mid-turn), dead->live on respawn, live->hibernated on stop_engine. --- server/src/api/ws.ts | 3 ++ server/src/db/database.ts | 11 +++++ server/src/db/repos.ts | 8 ++++ server/src/sessions/SessionManager.ts | 2 + server/src/sessions/SessionRuntime.ts | 55 +++++++++++++++++++++++- server/test/e2e.test.ts | 53 +++++++++++++++++++++++ shared/src/api.ts | 7 ++- shared/src/events.ts | 11 +++++ shared/src/protocol.ts | 4 +- web/src/components/SessionSidebar.svelte | 21 ++++++++- web/src/components/StatusBar.svelte | 45 ++++++++++++++++++- web/src/lib/format.ts | 7 ++- web/src/lib/ws.ts | 4 ++ 13 files changed, 224 insertions(+), 7 deletions(-) diff --git a/server/src/api/ws.ts b/server/src/api/ws.ts index 8f14ba0..d28b53b 100644 --- a/server/src/api/ws.ts +++ b/server/src/api/ws.ts @@ -135,6 +135,9 @@ export function attachWebSocket(server: HttpServer, deps: WsDeps): WebSocketServ case 'interrupt': void deps.manager.runtime(msg.sessionId).interrupt(); return; + case 'stop_engine': + deps.manager.runtime(msg.sessionId).stopEngine(); + return; } } diff --git a/server/src/db/database.ts b/server/src/db/database.ts index a7cfbd5..1c1d4b4 100644 --- a/server/src/db/database.ts +++ b/server/src/db/database.ts @@ -55,6 +55,17 @@ UPDATE sessions SET permission_mode = CASE approval_mode WHEN 'full_auto' THEN 'bypassPermissions' ELSE NULL END; +`, + }, + { + // Track engine-process liveness separately from the interaction status. + // Existing sessions with an engine session are resumable -> hibernated. + id: 3, + sql: ` +ALTER TABLE sessions ADD COLUMN engine_state TEXT NOT NULL DEFAULT 'new'; + +UPDATE sessions SET engine_state = + CASE WHEN engine_session_id IS NOT NULL THEN 'hibernated' ELSE 'new' END; `, }, ]; diff --git a/server/src/db/repos.ts b/server/src/db/repos.ts index 67aa2c3..369dd76 100644 --- a/server/src/db/repos.ts +++ b/server/src/db/repos.ts @@ -1,5 +1,6 @@ import type Database from 'better-sqlite3'; import type { + EngineState, NormalizedEvent, PermissionMode, SessionDto, @@ -15,6 +16,7 @@ interface SessionRow { cwd: string; permission_mode: string | null; status: string; + engine_state: string; pid: number | null; created_at: number; updated_at: number; @@ -34,6 +36,8 @@ function toDto(r: SessionRow): SessionDto { cwd: r.cwd, permissionMode: (r.permission_mode as PermissionMode | null) ?? null, status: r.status as SessionStatus, + engineState: (r.engine_state as EngineState) ?? 'new', + enginePid: r.pid, createdAt: r.created_at, updatedAt: r.updated_at, archivedAt: r.archived_at, @@ -120,6 +124,10 @@ export class SessionsRepo { this.db.prepare('UPDATE sessions SET pid = ? WHERE id = ?').run(pid, id); } + setEngineState(id: string, state: EngineState): void { + this.db.prepare('UPDATE sessions SET engine_state = ? WHERE id = ?').run(state, id); + } + setEngineSessionId(id: string, engineSessionId: string): void { this.db .prepare('UPDATE sessions SET engine_session_id = ? WHERE id = ?') diff --git a/server/src/sessions/SessionManager.ts b/server/src/sessions/SessionManager.ts index 013d4e2..2074e8d 100644 --- a/server/src/sessions/SessionManager.ts +++ b/server/src/sessions/SessionManager.ts @@ -41,6 +41,8 @@ export class SessionManager { } } this.sessions.setPid(s.id, null); + // The process is gone but the session stays resumable. + this.sessions.setEngineState(s.id, 'hibernated'); } for (const s of this.sessions.list(true)) { for (const { requestId } of this.events.unresolvedApprovals(s.id)) { diff --git a/server/src/sessions/SessionRuntime.ts b/server/src/sessions/SessionRuntime.ts index cb21135..e14108d 100644 --- a/server/src/sessions/SessionRuntime.ts +++ b/server/src/sessions/SessionRuntime.ts @@ -1,5 +1,5 @@ import { randomUUID } from 'node:crypto'; -import type { NormalizedEvent, SessionDto, SessionStatus } from '@hub/shared'; +import type { EngineState, NormalizedEvent, SessionDto, SessionStatus } from '@hub/shared'; import type { HubBus } from '../bus.js'; import type { HubConfig } from '../config.js'; import type { EventsRepo, SessionsRepo } from '../db/repos.js'; @@ -21,6 +21,9 @@ export class SessionRuntime { private approvalTimers = new Map>(); /** Approvals waiting on the user (requestIds). */ private pendingUserApprovals = new Set(); + /** True while we are intentionally tearing the engine down (idle/stop/delete), + * so its exit reads as `hibernated` rather than a crash (`dead`). */ + private disposing = false; constructor( private session: SessionDto, @@ -45,6 +48,11 @@ export class SessionRuntime { this.bus.emit('status', this.session.id, status); } + private setEngineState(state: EngineState): void { + this.sessions.setEngineState(this.session.id, state); + this.emitUpsert(); + } + private persist(event: NormalizedEvent): void { const row = this.events.append(this.session.id, event); this.bus.emit('event', row); @@ -64,12 +72,31 @@ export class SessionRuntime { }); this.run = run; if (run.pid) this.sessions.setPid(this.session.id, run.pid); + this.setEngineState('live'); run.onEvent((e) => this.handleEngineEvent(e)); run.onDelta((messageKey, delta) => this.bus.emit('delta', this.session.id, messageKey, delta)); run.onExit(() => { this.sessions.setPid(this.session.id, null); - if (this.run === run) this.run = null; + const wasCurrent = this.run === run; + if (wasCurrent) this.run = null; + if (this.disposing) { + // An intentional teardown (idle/stop/delete) already set the state. + this.disposing = false; + return; + } + if (wasCurrent) { + // Unexpected exit while still the live run = a crash. If a turn is still + // open (busy => the adapter didn't emit a terminal 'done', e.g. SIGKILL), + // close it so the session doesn't hang in 'running'. + if (this.busy) { + this.busy = false; + this.clearApprovalState(); + this.persist({ type: 'done', reason: 'error', errorMessage: 'engine process exited unexpectedly' }); + this.setStatus('error'); + } + this.setEngineState('dead'); + } }); return run; } @@ -140,13 +167,36 @@ export class SessionRuntime { this.idleTimer = setTimeout(() => { if (!this.busy && this.run) { // Engine process is torn down; the session stays resumable. + this.disposing = true; this.run.dispose(); this.run = null; + this.sessions.setPid(this.session.id, null); + this.setEngineState('hibernated'); } }, this.config.engineIdleTimeoutMs); this.idleTimer.unref?.(); } + /** + * Explicitly tear down the live engine process now (e.g. a user "stop"), + * leaving the session resumable. If a turn is in flight it is ended. + */ + stopEngine(): void { + if (!this.run) return; + this.disposing = true; + if (this.idleTimer) clearTimeout(this.idleTimer); + if (this.busy) { + this.busy = false; + this.clearApprovalState(); + this.persist({ type: 'done', reason: 'killed' }); + this.setStatus('idle'); + } + this.run.dispose(); + this.run = null; + this.sessions.setPid(this.session.id, null); + this.setEngineState('hibernated'); + } + sendUserMessage(text: string): void { if (this.busy) { throw new HubError('session_busy', 'A turn is already in progress; interrupt it first.'); @@ -193,6 +243,7 @@ export class SessionRuntime { dispose(): void { if (this.idleTimer) clearTimeout(this.idleTimer); this.clearApprovalState(); + this.disposing = true; this.run?.dispose(); this.run = null; } diff --git a/server/test/e2e.test.ts b/server/test/e2e.test.ts index 03cede8..62556eb 100644 --- a/server/test/e2e.test.ts +++ b/server/test/e2e.test.ts @@ -286,4 +286,57 @@ describe('end-to-end with echo engine', () => { c.close(); await server.close(); }, 15_000); + + it('surfaces engine liveness and stop_engine hibernates a resumable process', async () => { + server = await buildServer(testConfig(dataDir, workspace)); + const port = server.address.port; + const c = new Client(port); + await c.open(); + c.send({ type: 'auth', token: TOKEN }); + await c.next((m) => m.type === 'auth_ok'); + + // A brand-new session has not spawned an engine yet. + const session = await api(port, 'POST', '/api/sessions', { + engineId: 'echo', + cwd: workspace, + permissionMode: 'bypassPermissions', + }); + expect(session.engineState).toBe('new'); + + // First turn spawns the engine -> live (idle timeout is 0 here, so it stays). + c.send({ type: 'subscribe', sessionId: session.id }); + c.send({ type: 'user_message', sessionId: session.id, text: 'wake up' }); + const firstDone = await c.next( + (m) => m.type === 'event' && m.sessionId === session.id && m.event.type === 'done', + 10_000, + ); + const firstDoneId = (firstDone as Extract).eventId; + const live = await api(port, 'GET', `/api/sessions/${session.id}`); + expect(live.engineState).toBe('live'); + + // Explicit stop tears the process down but keeps the session resumable. + c.send({ type: 'stop_engine', sessionId: session.id }); + await c.next( + (m) => m.type === 'session_upsert' && m.session.id === session.id && m.session.engineState === 'hibernated', + ); + const hibernated = await api(port, 'GET', `/api/sessions/${session.id}`); + expect(hibernated.engineState).toBe('hibernated'); + expect(hibernated.enginePid).toBeNull(); + + // A new message respawns it; wait for the NEW turn's done (by event id), then verify live. + c.send({ type: 'user_message', sessionId: session.id, text: 'and again' }); + await c.next( + (m) => + m.type === 'event' && + m.sessionId === session.id && + m.event.type === 'done' && + m.eventId > firstDoneId, + 10_000, + ); + const relive = await api(port, 'GET', `/api/sessions/${session.id}`); + expect(relive.engineState).toBe('live'); + + c.close(); + await server.close(); + }, 15_000); }); diff --git a/shared/src/api.ts b/shared/src/api.ts index 01d2a26..cb901a0 100644 --- a/shared/src/api.ts +++ b/shared/src/api.ts @@ -1,4 +1,4 @@ -import type { ApprovalMode, PermissionMode, SessionStatus } from './events.js'; +import type { ApprovalMode, EngineState, PermissionMode, SessionStatus } from './events.js'; export interface EngineDescriptor { id: string; @@ -21,7 +21,12 @@ export interface SessionDto { /** Canonical permission control, passed to the CLI as --permission-mode. * null = inherit the CLI / ~/.claude default. */ permissionMode: PermissionMode | null; + /** Interaction state of the current turn. */ status: SessionStatus; + /** Engine-process liveness, independent of `status`. */ + engineState: EngineState; + /** OS pid of the live engine process, or null when not running. */ + enginePid: number | null; createdAt: number; updatedAt: number; archivedAt: number | null; diff --git a/shared/src/events.ts b/shared/src/events.ts index 30ff35a..efff672 100644 --- a/shared/src/events.ts +++ b/shared/src/events.ts @@ -26,6 +26,17 @@ export type SessionStatus = | 'awaiting_approval' | 'error'; +/** + * Engine-process liveness, independent of the interaction `status`. A session + * is a terminal window that keeps its scrollback: the process can be gone while + * the session stays resumable. + * - new: never started (spawns lazily on the first message) + * - live: the engine child process is running and attached + * - hibernated: torn down to save resources (idle timeout or stop); resumable + * - dead: exited unexpectedly (crash); still resumable on the next message + */ +export type EngineState = 'new' | 'live' | 'hibernated' | 'dead'; + export interface UsageSummary { inputTokens?: number; outputTokens?: number; diff --git a/shared/src/protocol.ts b/shared/src/protocol.ts index db807cf..6f59a6f 100644 --- a/shared/src/protocol.ts +++ b/shared/src/protocol.ts @@ -19,7 +19,9 @@ export type ClientMsg = behavior: 'allow' | 'deny'; message?: string; } - | { type: 'interrupt'; sessionId: string }; + | { type: 'interrupt'; sessionId: string } + /** Tear down the live engine process now; the session stays resumable. */ + | { type: 'stop_engine'; sessionId: string }; export type ServerMsg = | { type: 'auth_ok' } diff --git a/web/src/components/SessionSidebar.svelte b/web/src/components/SessionSidebar.svelte index 297e74d..a8200fb 100644 --- a/web/src/components/SessionSidebar.svelte +++ b/web/src/components/SessionSidebar.svelte @@ -2,7 +2,7 @@ import { activeSessionId, connState, sessions, statusBySession, token } from '../lib/stores.js'; import { api } from '../lib/api.js'; import { disconnect, subscribeSession } from '../lib/ws.js'; - import { isDangerMode, relativeTime } from '../lib/format.js'; + import { engineStateLabel, isDangerMode, relativeTime } from '../lib/format.js'; import NewSessionDialog from './NewSessionDialog.svelte'; let { showToast }: { showToast: (m: string) => void } = $props(); @@ -91,6 +91,11 @@ {s.title} {/if} + {s.engineId} {#if isDangerMode(s.permissionMode)}BYPASS{/if} {relativeTime(s.updatedAt)} @@ -201,10 +206,24 @@ } .sub { display: flex; + align-items: center; gap: 8px; font-size: 11px; color: var(--text-dim); } + .engine-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--text-dim); + flex: none; + } + .engine-dot[data-engine='live'] { + background: var(--green); + } + .engine-dot[data-engine='dead'] { + background: var(--red); + } .engine { font-family: var(--mono); } diff --git a/web/src/components/StatusBar.svelte b/web/src/components/StatusBar.svelte index 1a3a390..18ea692 100644 --- a/web/src/components/StatusBar.svelte +++ b/web/src/components/StatusBar.svelte @@ -1,7 +1,8 @@