Files
claude-code-conduit/bin/ccc-queue.mjs
mikael-lovqvists-claude-agent dd7743501a Add runtime policy overrides and TUI policy management view
- server/policy_overrides.mjs: persistent group/action override store
- Group toggle: disabling a group forces all its actions to auto-deny without
  touching individual overrides; re-enabling restores them intact
- Action overrides: cycle auto-accept/queue/auto-deny/clear per action
- Effective policy resolution in index.mjs: group > action override > hardcoded default
- auth.mjs: add check_can_manage_policies (requires canApprove.length > 0)
- Three new endpoints: GET /policies, POST /policies/group/:group, POST /policies/action/:action
- client/conduit.mjs: get_policies, set_group_policy, set_action_policy
- ccc-queue.mjs: policy view (Tab to switch); group/action panel with space/e/←/→
- actions.mjs: group fields on desktop/email/calendar actions; list-actions includes group
- config.example.json: add policy_overrides key
- Bump version to 1.3.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 16:01:45 +00:00

351 lines
11 KiB
JavaScript
Executable File

#!/usr/bin/env node
import blessed from 'blessed';
import { load_client_config } from '../client/config.mjs';
import { create_conduit_client } from '../client/conduit.mjs';
const POLL_INTERVAL = 2000;
const VALID_POLICIES = ['auto-accept', 'queue', 'auto-deny'];
const { username, secret, url } = load_client_config(process.argv);
const client = create_conduit_client(username, secret, url);
// ── State ─────────────────────────────────────────────────────────────────────
class App_State {
constructor() {
this.view = 'queue'; // 'queue' | 'policies'
this.queue_items = [];
this.policy_state = null; // { groups, action_overrides, effective }
this.all_actions = []; // from list-actions, includes group field
this.left_focused = true; // in policies view: which panel has focus
this.sel_group = 0;
this.sel_action = 0;
}
known_groups() {
return [...new Set(this.all_actions.map(a => a.group).filter(Boolean))].sort();
}
actions_in_group(group) {
return this.all_actions.filter(a => a.group === group);
}
current_group() {
return this.known_groups()[this.sel_group] ?? null;
}
current_action() {
const group = this.current_group();
if (!group) { return null; }
return this.actions_in_group(group)[this.sel_action] ?? null;
}
}
const state = new App_State();
// ── Layout ────────────────────────────────────────────────────────────────────
const screen = blessed.screen({ smartCSR: true, title: 'ccc-queue' });
const left_box = blessed.list({
top: 0, left: 0, width: '40%', height: '100%-3',
border: { type: 'line' },
scrollable: true, keys: true, vi: true, mouse: true, tags: true,
style: {
selected: { bg: 'blue', fg: 'white', bold: true },
border: { fg: 'cyan' },
label: { fg: 'cyan', bold: true },
},
});
const detail_box = blessed.box({
top: 0, left: '40%', width: '60%', height: '100%-3',
border: { type: 'line' },
label: ' Details ',
scrollable: true, alwaysScroll: true, tags: true,
style: { border: { fg: 'cyan' }, label: { fg: 'cyan', bold: true } },
});
const actions_list = blessed.list({
top: 0, left: '40%', width: '60%', height: '100%-3',
border: { type: 'line' },
label: ' Actions ',
scrollable: true, keys: true, vi: true, mouse: true, tags: true,
style: {
selected: { bg: 'blue', fg: 'white', bold: true },
border: { fg: 'cyan' },
label: { fg: 'cyan', bold: true },
},
});
const status_bar = blessed.box({
bottom: 0, left: 0, width: '100%', height: 3,
border: { type: 'line' }, tags: true,
style: { border: { fg: 'grey' } },
});
screen.append(left_box);
screen.append(detail_box);
screen.append(actions_list);
screen.append(status_bar);
// ── Rendering ─────────────────────────────────────────────────────────────────
function policy_color(policy) {
if (policy === 'auto-deny') { return 'red'; }
if (policy === 'auto-accept') { return 'green'; }
if (policy === 'queue') { return 'yellow'; }
return 'grey';
}
function set_status(text, is_error = false) {
const msg_color = is_error ? 'red' : 'green';
let keys;
if (state.view === 'queue') {
keys = `{grey-fg}[y]{/grey-fg} approve {grey-fg}[n]{/grey-fg} deny ` +
`{grey-fg}[r]{/grey-fg} refresh {grey-fg}[tab]{/grey-fg} policies {grey-fg}[q]{/grey-fg} quit`;
} else if (state.left_focused) {
keys = `{grey-fg}[space]{/grey-fg} toggle group {grey-fg}[→]{/grey-fg} edit actions ` +
`{grey-fg}[tab]{/grey-fg} queue {grey-fg}[q]{/grey-fg} quit`;
} else {
keys = `{grey-fg}[e]{/grey-fg} cycle policy {grey-fg}[←]{/grey-fg} groups ` +
`{grey-fg}[tab]{/grey-fg} queue {grey-fg}[q]{/grey-fg} quit`;
}
status_bar.setContent(` {${msg_color}-fg}${text}{/${msg_color}-fg} ${keys}`);
screen.render();
}
function render_queue() {
left_box.setLabel(' Pending Actions ');
detail_box.show();
actions_list.hide();
const sel = left_box.selected ?? 0;
left_box.clearItems();
if (state.queue_items.length === 0) {
left_box.addItem('{grey-fg}(no pending items){/grey-fg}');
} else {
for (const item of state.queue_items) {
left_box.addItem(`{yellow-fg}${item.id.slice(0, 8)}{/yellow-fg} ${item.action}`);
}
left_box.select(Math.min(sel, state.queue_items.length - 1));
}
const item = state.queue_items[left_box.selected] ?? null;
if (!item) {
detail_box.setContent('{grey-fg}No item selected{/grey-fg}');
} else {
const lines = [
`{bold}Action:{/bold} ${item.action}`,
`{bold}ID:{/bold} ${item.id}`,
`{bold}Submitted by:{/bold} ${item.submitted_by}`,
`{bold}Created:{/bold} ${item.created_at}`,
'',
'{bold}Params:{/bold}',
];
for (const [k, v] of Object.entries(item.params ?? {})) {
lines.push(` {cyan-fg}${k}{/cyan-fg}: ${v}`);
}
detail_box.setContent(lines.join('\n'));
}
left_box.focus();
screen.render();
}
function render_policies() {
left_box.setLabel(' Groups ');
detail_box.hide();
actions_list.show();
const groups = state.known_groups();
// Left: groups
left_box.clearItems();
if (groups.length === 0) {
left_box.addItem('{grey-fg}(no groups defined){/grey-fg}');
} else {
for (const g of groups) {
const disabled = state.policy_state?.groups?.[g] ?? false;
const dot = disabled ? '{red-fg}[○]{/red-fg}' : '{green-fg}[●]{/green-fg}';
const label = disabled ? `{grey-fg}${g} (off){/grey-fg}` : g;
left_box.addItem(`${dot} ${label}`);
}
left_box.select(Math.min(state.sel_group, groups.length - 1));
}
// Right: actions in selected group
const group = state.current_group();
const group_disabled = group ? (state.policy_state?.groups?.[group] ?? false) : false;
const acts = group ? state.actions_in_group(group) : [];
actions_list.clearItems();
if (acts.length === 0) {
actions_list.addItem('{grey-fg}(no actions){/grey-fg}');
} else {
for (const a of acts) {
const effective = state.policy_state?.effective?.[a.action] ?? a.policy;
const has_override = state.policy_state?.action_overrides?.[a.action] !== undefined;
const color = group_disabled ? 'grey' : policy_color(effective);
const marker = has_override ? ' {white-fg}*{/white-fg}' : '';
actions_list.addItem(
`{${color}-fg}${effective.padEnd(12)}{/${color}-fg}${marker} ${a.action}`
);
}
actions_list.select(Math.min(state.sel_action, acts.length - 1));
}
// Highlight focused panel
if (state.left_focused) {
left_box.style.border.fg = 'cyan';
actions_list.style.border.fg = 'grey';
left_box.focus();
} else {
left_box.style.border.fg = 'grey';
actions_list.style.border.fg = 'cyan';
actions_list.focus();
}
screen.render();
}
function render() {
if (state.view === 'queue') {
render_queue();
} else {
render_policies();
}
}
// ── Data fetching ─────────────────────────────────────────────────────────────
async function refresh() {
try {
const [queue_result, policy_result, actions_result] = await Promise.all([
client.get_queue(),
client.get_policies(),
client.list_actions(),
]);
state.queue_items = Array.isArray(queue_result) ? queue_result : [];
state.policy_state = policy_result;
state.all_actions = actions_result?.result ?? [];
render();
set_status(`Last refresh: ${new Date().toLocaleTimeString()}`);
} catch (err) {
set_status(`Refresh failed: ${err.message}`, true);
}
}
// ── Queue operations ──────────────────────────────────────────────────────────
async function decide(decision) {
const item = state.queue_items[left_box.selected];
if (!item) { return; }
set_status(`Sending ${decision} for ${item.id.slice(0, 8)}`);
try {
await client.resolve_queue_item(item.id, decision);
await refresh();
} catch (err) {
set_status(`Failed: ${err.message}`, true);
}
}
// ── Policy operations ─────────────────────────────────────────────────────────
async function toggle_group() {
const group = state.current_group();
if (!group) { return; }
const currently_disabled = state.policy_state?.groups?.[group] ?? false;
set_status(`Toggling group '${group}'…`);
try {
await client.set_group_policy(group, !currently_disabled);
await refresh();
} catch (err) {
set_status(`Failed: ${err.message}`, true);
}
}
async function cycle_action_policy() {
const act = state.current_action();
if (!act) { return; }
const current_override = state.policy_state?.action_overrides?.[act.action] ?? null;
// Cycle: auto-accept → queue → auto-deny → null (clear) → auto-accept → …
const next_idx = current_override === null
? 0
: (VALID_POLICIES.indexOf(current_override) + 1) % (VALID_POLICIES.length + 1);
const next = next_idx < VALID_POLICIES.length ? VALID_POLICIES[next_idx] : null;
set_status(`Setting '${act.action}' → ${next ?? '(default)'}`);
try {
await client.set_action_policy(act.action, next);
await refresh();
} catch (err) {
set_status(`Failed: ${err.message}`, true);
}
}
// ── Key bindings ──────────────────────────────────────────────────────────────
screen.key(['q', 'C-c'], () => process.exit(0));
screen.key('r', refresh);
screen.key('tab', () => {
state.view = state.view === 'queue' ? 'policies' : 'queue';
state.left_focused = true;
render();
set_status(`Switched to ${state.view} view`);
});
// Queue view
screen.key('y', () => { if (state.view === 'queue') { decide('approve'); } });
screen.key('n', () => { if (state.view === 'queue') { decide('deny'); } });
// Policy view
screen.key('space', () => {
if (state.view === 'policies' && state.left_focused) { toggle_group(); }
});
screen.key('e', () => {
if (state.view === 'policies' && !state.left_focused) { cycle_action_policy(); }
});
screen.key('right', () => {
if (state.view === 'policies' && state.left_focused) {
state.left_focused = false;
state.sel_action = 0;
render();
set_status('');
}
});
screen.key('left', () => {
if (state.view === 'policies' && !state.left_focused) {
state.left_focused = true;
render();
set_status('');
}
});
// Sync selection state from list navigation
left_box.on('select item', () => {
if (state.view === 'queue') {
render_queue();
} else {
state.sel_group = left_box.selected ?? 0;
state.sel_action = 0;
render_policies();
}
screen.render();
});
actions_list.on('select item', () => {
if (state.view === 'policies') {
state.sel_action = actions_list.selected ?? 0;
screen.render();
}
});
// ── Boot ──────────────────────────────────────────────────────────────────────
left_box.focus();
set_status('Connecting…');
refresh();
const poll = setInterval(refresh, POLL_INTERVAL);
poll.unref();