@cirvix_ai/agent-control 0.1.5 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Cirvix semantic theme system.
3
+ *
4
+ * Every color in the product goes through here. No file outside this one
5
+ * (and its thin re-export in `format.mjs`) may hardcode an ANSI code or call
6
+ * a raw color helper for a product meaning.
7
+ *
8
+ * Why semantic, not literal:
9
+ * - `allow` always means "this proceeded". If it is green-on-dark today and
10
+ * bright-green-on-light tomorrow, every call site updates at once.
11
+ * - Themes become data: `cirvix theme light` swaps one table, not 40 files.
12
+ * - Accessibility (high-contrast, monochrome) is a theme, not a refactor.
13
+ * - Tests can assert meaning ("blocked renders with the block role") without
14
+ * asserting escape bytes.
15
+ *
16
+ * Zero dependencies. Works with NO_COLOR / dumb terminals / pipes by
17
+ * producing plain text (same contract `format.mjs` always had).
18
+ */
19
+
20
+ const ANSI = {
21
+ reset: "\x1b[0m",
22
+ bold: ["\x1b[1m", "\x1b[22m"],
23
+ dim: ["\x1b[2m", "\x1b[22m"],
24
+ };
25
+
26
+ /** Role names. Adding a role = adding a key here + every theme below. */
27
+ export const ROLES = [
28
+ "text",
29
+ "muted",
30
+ "accent",
31
+ "allow",
32
+ "sanitize",
33
+ "block",
34
+ "hold",
35
+ "info",
36
+ "warning",
37
+ "error",
38
+ "border",
39
+ "surface",
40
+ "selection",
41
+ ];
42
+
43
+ /**
44
+ * Themes are { role: [open, close] } ANSI pairs, or null for "no styling".
45
+ * Keep the numbers standard (30-37 / 90-97) so they survive SSH, tmux,
46
+ * Windows Terminal, and CI log renderers.
47
+ */
48
+ const THEMES = {
49
+ dark: {
50
+ text: [37, 39], // white
51
+ muted: [90, 39], // bright black (grey)
52
+ accent: [36, 39], // cyan
53
+ allow: [32, 39], // green
54
+ sanitize: [36, 39], // cyan
55
+ block: [31, 39], // red
56
+ hold: [33, 39], // yellow
57
+ info: [34, 39], // blue
58
+ warning: [33, 39], // yellow
59
+ error: [31, 39], // red
60
+ border: [90, 39],
61
+ surface: null,
62
+ selection: [36, 39],
63
+ },
64
+ light: {
65
+ text: [30, 39], // black
66
+ muted: [90, 39],
67
+ accent: [36, 39],
68
+ allow: [32, 39], // green reads on light bg; darker terminals vary but stay legible
69
+ sanitize: [36, 39],
70
+ block: [31, 39],
71
+ hold: [33, 39],
72
+ info: [34, 39],
73
+ warning: [33, 39],
74
+ error: [31, 39],
75
+ border: [90, 39],
76
+ surface: null,
77
+ selection: [36, 39],
78
+ },
79
+ midnight: {
80
+ text: [97, 39], // bright white
81
+ muted: [34, 39], // dim blue-grey feel
82
+ accent: [95, 39], // bright magenta
83
+ allow: [92, 39], // bright green
84
+ sanitize: [96, 39], // bright cyan
85
+ block: [91, 39], // bright red
86
+ hold: [93, 39], // bright yellow
87
+ info: [94, 39], // bright blue
88
+ warning: [93, 39],
89
+ error: [91, 39],
90
+ border: [35, 39], // magenta borders
91
+ surface: null,
92
+ selection: [95, 39],
93
+ },
94
+ "high-contrast": {
95
+ text: [97, 39],
96
+ muted: [37, 39], // no dim grey — everything legible
97
+ accent: [93, 39],
98
+ allow: [92, 39],
99
+ sanitize: [96, 39],
100
+ block: [91, 39],
101
+ hold: [93, 39],
102
+ info: [94, 39],
103
+ warning: [93, 39],
104
+ error: [91, 39],
105
+ border: [97, 39],
106
+ surface: null,
107
+ selection: [93, 39],
108
+ },
109
+ monochrome: {
110
+ text: null,
111
+ muted: null,
112
+ accent: null,
113
+ allow: null,
114
+ sanitize: null,
115
+ block: null,
116
+ hold: null,
117
+ info: null,
118
+ warning: null,
119
+ error: null,
120
+ border: null,
121
+ surface: null,
122
+ selection: null,
123
+ },
124
+ };
125
+
126
+ export const THEME_NAMES = Object.keys(THEMES);
127
+
128
+ let current = process.env.CIRVIX_THEME && THEMES[process.env.CIRVIX_THEME]
129
+ ? process.env.CIRVIX_THEME
130
+ : "dark";
131
+
132
+ function colorEnabled() {
133
+ if (process.env.FORCE_COLOR === "1" || process.env.FORCE_COLOR === "true") return true;
134
+ if (process.env.NO_COLOR !== undefined) return false;
135
+ if (process.env.TERM === "dumb") return false;
136
+ if (process.env.CIRVIX_THEME === "monochrome") return false;
137
+ // Non-TTY (pipes, CI logs) → plain text. Same rule format.mjs always had.
138
+ if (!process.stdout.isTTY) return false;
139
+ return true;
140
+ }
141
+
142
+ function wrapAnsi(open, close) {
143
+ return (s) => (colorEnabled() ? `\x1b[${open}m${String(s)}\x1b[${close}m` : String(s));
144
+ }
145
+
146
+ /** Set the active theme. Returns the name set. Throws on unknown names. */
147
+ export function setTheme(name) {
148
+ if (!THEMES[name]) throw new Error(`Unknown theme "${name}". Available: ${THEME_NAMES.join(", ")}`);
149
+ current = name;
150
+ return current;
151
+ }
152
+
153
+ /** Active theme name. */
154
+ export function themeName() {
155
+ return current;
156
+ }
157
+
158
+ /** Raw role → ANSI pair for the active theme (or null). */
159
+ export function roleAnsi(role) {
160
+ return THEMES[current]?.[role] ?? null;
161
+ }
162
+
163
+ /**
164
+ * Style a string with a semantic role: `style("BLOCKED", "block")`.
165
+ * Unknown roles pass through unstyled rather than throwing — a theme
166
+ * must never break enforcement output.
167
+ */
168
+ export function style(text, role) {
169
+ const pair = THEMES[current]?.[role];
170
+ if (!pair || !colorEnabled()) return String(text);
171
+ return `\x1b[${pair[0]}m${String(text)}\x1b[${pair[1]}m`;
172
+ }
173
+
174
+ /** Bold / dim are emphasis, not color — they survive monochrome. */
175
+ export function bold(s) {
176
+ if (!colorEnabled()) return String(s);
177
+ return `${ANSI.bold[0]}${String(s)}${ANSI.bold[1]}`;
178
+ }
179
+
180
+ export function dim(s) {
181
+ if (process.env.CIRVIX_THEME === "high-contrast") return String(s); // contrast: never dim
182
+ if (!colorEnabled()) return String(s);
183
+ return `${ANSI.dim[0]}${String(s)}${ANSI.dim[1]}`;
184
+ }
185
+
186
+ /**
187
+ * The semantic palette. Prefer `colors.block("…")` over importing raw
188
+ * helpers — call sites name the meaning, this file owns the rendering.
189
+ */
190
+ export const colors = {
191
+ get text() { return (s) => style(s, "text"); },
192
+ get muted() { return (s) => style(s, "muted"); },
193
+ get accent() { return (s) => style(s, "accent"); },
194
+ get allow() { return (s) => style(s, "allow"); },
195
+ get sanitize() { return (s) => style(s, "sanitize"); },
196
+ get block() { return (s) => style(s, "block"); },
197
+ get hold() { return (s) => style(s, "hold"); },
198
+ get info() { return (s) => style(s, "info"); },
199
+ get warning() { return (s) => style(s, "warning"); },
200
+ get error() { return (s) => style(s, "error"); },
201
+ get border() { return (s) => style(s, "border"); },
202
+ get selection() { return (s) => style(s, "selection"); },
203
+ };
204
+
205
+ /** Decision → theme role. Single mapping, used by every renderer. */
206
+ export function roleForDecision(decision) {
207
+ switch (String(decision ?? "").toLowerCase()) {
208
+ case "allow": return "allow";
209
+ case "sanitize": return "sanitize";
210
+ case "deny": return "block";
211
+ case "require_approval": return "hold";
212
+ case "audit_only": return "muted";
213
+ default: return "muted";
214
+ }
215
+ }
216
+
217
+ /** Risk → theme role. */
218
+ export function roleForRisk(risk) {
219
+ switch (String(risk ?? "").toLowerCase()) {
220
+ case "critical": return "error";
221
+ case "high": return "warning";
222
+ case "medium": return "info";
223
+ case "low": return "muted";
224
+ default: return "muted";
225
+ }
226
+ }
227
+
228
+ /** Decision → icon + label. Icon is never the only signal (a11y). */
229
+ export function badgeForDecision(decision) {
230
+ switch (String(decision ?? "").toLowerCase()) {
231
+ case "allow": return { icon: "✓", label: "ALLOWED" };
232
+ case "sanitize": return { icon: "◇", label: "SANITIZED" };
233
+ case "deny": return { icon: "✕", label: "BLOCKED" };
234
+ case "require_approval": return { icon: "◷", label: "HELD FOR APPROVAL" };
235
+ case "audit_only": return { icon: "○", label: "AUDIT ONLY" };
236
+ default: return { icon: "?", label: String(decision ?? "UNKNOWN").toUpperCase() };
237
+ }
238
+ }
239
+
240
+ export { wrapAnsi };
package/src/index.mjs CHANGED
@@ -140,6 +140,48 @@ export { scan } from "./commands/scan.mjs";
140
140
  export { init, STARTER_POLICY } from "./commands/init.mjs";
141
141
  export { status } from "./commands/status.mjs";
142
142
  export { demo } from "./commands/demo.mjs";
143
+ export { consoleCmd as console } from "./commands/console.mjs";
144
+ export { onboard } from "./commands/onboard.mjs";
145
+
146
+ /* Product UI (engine stays presentation-free; these render events) -------- */
147
+ export {
148
+ THEME_NAMES,
149
+ THEME_NAMES as THEMES,
150
+ ROLES,
151
+ badgeForDecision,
152
+ colors,
153
+ roleForDecision,
154
+ roleForRisk,
155
+ setTheme,
156
+ style,
157
+ themeName,
158
+ } from "./core/theme.mjs";
159
+ export {
160
+ EVENT,
161
+ EventBus,
162
+ attachPipeline,
163
+ createEvent,
164
+ initialState,
165
+ latencyStats,
166
+ reduce,
167
+ } from "./core/events.mjs";
168
+ export {
169
+ blockedCard,
170
+ cirvixRow,
171
+ explainDecision,
172
+ frame,
173
+ header,
174
+ heldCard,
175
+ policyCard,
176
+ rule,
177
+ spinnerFrame,
178
+ toolCard,
179
+ userRow,
180
+ } from "./tui/cards.mjs";
181
+ export { statusBar } from "./tui/status.mjs";
182
+ export { activitySummary, collapsedFeed, activityRow } from "./tui/activity.mjs";
183
+ export { COMMANDS, filterCommands, paletteBox } from "./tui/palette.mjs";
184
+ export { ConsoleApp, parseRequest } from "./tui/app.mjs";
143
185
  export { check as policyCheck, explain as policyExplain, list as policyList, loadPolicyFile, test as policyTest } from "./commands/policy.mjs";
144
186
 
145
187
  /* Adapters & Platform ---------------------------------------------------- */
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Collapsible activity feed — the fix for the "wall of logs".
3
+ *
4
+ * 100 tool calls never dump as 100 lines. They collapse to:
5
+ *
6
+ * ✓ Deployment completed
7
+ * 14 actions ├─ 8 allowed ├─ 4 sanitized └─ 2 blocked
8
+ * [Enter] inspect
9
+ *
10
+ * Expanding (Enter / click / `inspect`) shows the per-call rows with the
11
+ * semantic badge (icon + color + WORD — never icon alone).
12
+ */
13
+
14
+ import { style, bold, dim, badgeForDecision, roleForDecision } from "../core/theme.mjs";
15
+
16
+ export function activitySummary(activity) {
17
+ const counts = { allow: 0, sanitize: 0, deny: 0, require_approval: 0, audit_only: 0 };
18
+ for (const e of activity) {
19
+ const d = e.decision ?? e.raw?.decision ?? "allow";
20
+ if (d in counts) counts[d]++;
21
+ }
22
+ return { total: activity.length, ...counts };
23
+ }
24
+
25
+ export function collapsedFeed(activity, { expanded = false, maxRows = 8 } = {}) {
26
+ const sum = activitySummary(activity);
27
+ if (sum.total === 0) return dim("No activity yet. Ask Cirvix to evaluate something.");
28
+
29
+ const head = `${style("✓", "allow")} ${bold(summaryTitle(sum))}\n\n` +
30
+ ` ${dim(`${sum.total} actions`)}\n` +
31
+ ` ${dim("├─")} ${sum.allow} allowed\n` +
32
+ ` ${dim("├─")} ${sum.sanitize} sanitized\n` +
33
+ ` ${dim("└─")} ${sum.deny + sum.require_approval} blocked/held\n\n` +
34
+ ` ${dim("[Enter] inspect")}`;
35
+
36
+ if (!expanded) return head;
37
+
38
+ const rows = activity.slice(-maxRows).map((e) => activityRow(e)).join("\n");
39
+ const more = sum.total > maxRows ? dim(` … ${sum.total - maxRows} earlier (collapsed)`) + "\n" : "";
40
+ return `▼ ${bold("Activity")}\n\n${more}${rows}`;
41
+ }
42
+
43
+ function summaryTitle(sum) {
44
+ if (sum.deny > 0) return `Session activity — ${sum.deny} blocked`;
45
+ if (sum.require_approval > 0) return `Session activity — ${sum.require_approval} held`;
46
+ return `Session activity — all clear`;
47
+ }
48
+
49
+ export function activityRow(event) {
50
+ const d = event.decision ?? event.raw?.decision ?? "allow";
51
+ const badge = badgeForDecision(d);
52
+ const role = roleForDecision(d);
53
+ const when = clock(event.ts);
54
+ const tool = event.tool ?? event.raw?.tool ?? "—";
55
+ const target = truncate(event.resource ?? event.raw?.resource ?? "", 36);
56
+ const suffix = d === "deny" ? style(" → blocked", "block")
57
+ : d === "sanitize" ? style(" → sanitized", "sanitize")
58
+ : d === "require_approval" ? style(" → held", "hold")
59
+ : "";
60
+ return ` ${dim(when)} ${style(`${badge.icon} ${tool}`, role)}${target ? dim(` ${target}`) : ""}${suffix}`;
61
+ }
62
+
63
+ function clock(ts) {
64
+ const m = String(ts ?? "").match(/T(\d{2}:\d{2}:\d{2})/);
65
+ return m ? m[1] : "--:--:--";
66
+ }
67
+
68
+ function truncate(s, n) {
69
+ const v = String(s ?? "");
70
+ return v.length <= n ? v : `…${v.slice(-(n - 1))}`;
71
+ }
@@ -0,0 +1,292 @@
1
+ /**
2
+ * Cirvix console — the primary interactive UX.
3
+ *
4
+ * Layout (wide terminals):
5
+ * ┌ header ────────────────────────────────┐
6
+ * │ CONVERSATION │ ACTIVITY │
7
+ * │ user asks / cirvix │ ◐ policy.eval│
8
+ * │ responds / policy cards │ ✓ shell.exec │
9
+ * ├ composer ──────────────────────────────┤
10
+ * │ status bar │
11
+ * └────────────────────────────────────────┘
12
+ *
13
+ * Narrow terminals (<90 cols): the activity pane collapses into the
14
+ * transcript automatically. No horizontal scrolling, ever.
15
+ *
16
+ * Engine separation: the app owns a Pipeline + EventBus. It emits events,
17
+ * reduces them to state, and renders cards from state. Policy code never
18
+ * touches the terminal.
19
+ *
20
+ * Non-TTY / piped usage: `runOnce(text)` evaluates one line and returns
21
+ * the rendered output without starting readline — used by tests, CI, and
22
+ * `echo "…" | cirvix console`.
23
+ */
24
+
25
+ import { Pipeline } from "../core/pipeline.mjs";
26
+ import { decideNow } from "../core/journal.mjs";
27
+ import { EventBus, createEvent, initialState, reduce, attachPipeline } from "../core/events.mjs";
28
+ import { header, policyCard, toolCard, blockedCard, heldCard, explainDecision, userRow, cirvixRow, spinnerFrame } from "./cards.mjs";
29
+ import { statusBar } from "./status.mjs";
30
+ import { collapsedFeed } from "./activity.mjs";
31
+ import { paletteBox } from "./palette.mjs";
32
+ import { setTheme, THEME_NAMES, bold, dim, style } from "../core/theme.mjs";
33
+ import { startComposer } from "./composer.mjs";
34
+
35
+ const VERSION = "0.1.0";
36
+
37
+ export class ConsoleApp {
38
+ constructor({ cwd = process.cwd(), rules = [], mode = "enforce", agent = "local", write = (s) => process.stdout.write(s) } = {}) {
39
+ this.cwd = cwd;
40
+ this.rules = rules;
41
+ this.agent = agent;
42
+ this.write = write;
43
+ this.bus = new EventBus();
44
+ this.state = initialState();
45
+ this.state.status.mode = mode;
46
+ this.expanded = false;
47
+ this.overlay = null; // 'palette' | null
48
+
49
+ this.pipeline = new Pipeline({ rules, cwd, agent, mode, onEvent: () => {} });
50
+ attachPipeline(this.pipeline, this.bus);
51
+ this.bus.onAny((e) => {
52
+ this.state = reduce(this.state, e);
53
+ });
54
+ this.bus.emit(createEvent.sessionStarted({ agent }));
55
+ }
56
+
57
+ /* ---------------------------------------------------------- rendering */
58
+
59
+ renderHeader() {
60
+ return header({ mode: this.state.status.mode === "audit" ? "AUDIT MODE" : "PROTECTED", version: VERSION });
61
+ }
62
+
63
+ renderStatus() {
64
+ return statusBar(this.state, { version: VERSION });
65
+ }
66
+
67
+ renderActivity() {
68
+ return collapsedFeed(this.state.activity, { expanded: this.expanded });
69
+ }
70
+
71
+ /** Full re-render of the transcript region (used on clear / toggle). */
72
+ renderTranscript() {
73
+ const out = [this.renderHeader(), ``];
74
+ for (const m of this.state.messages) {
75
+ out.push(m.role === "user" ? userRow(m.text) : cirvixRow(m.text), ``);
76
+ }
77
+ if (this.narrow()) {
78
+ out.push(`▼ Activity`, this.renderActivity(), ``);
79
+ }
80
+ out.push(this.renderStatus());
81
+ return out.join("\n");
82
+ }
83
+
84
+ narrow() {
85
+ return (process.stdout.columns ?? 80) < 90;
86
+ }
87
+
88
+ /* ------------------------------------------------------------- events */
89
+
90
+ /** Animated "evaluating" line. Resolves with a stop() that clears it. */
91
+ animate(label) {
92
+ if (!process.stdout.isTTY) {
93
+ this.write(`${dim("◐")} ${label}...\n`);
94
+ return () => {};
95
+ }
96
+ let i = 0;
97
+ const timer = setInterval(() => {
98
+ process.stdout.write(`\r${dim(spinnerFrame(i++))} ${dim(label)}...`);
99
+ }, 90);
100
+ return () => {
101
+ clearInterval(timer);
102
+ process.stdout.write("\r" + " ".repeat(label.length + 6) + "\r");
103
+ };
104
+ }
105
+
106
+ /** Evaluate one free-text line through policy and render the product UI. */
107
+ async runOnce(input) {
108
+ const text = String(input ?? "").trim();
109
+ if (!text) return "";
110
+ if (text.startsWith("/")) return this.runSlash(text);
111
+
112
+ this.bus.emit(createEvent.userMessage(text));
113
+ const lines = [userRow(text), ``, cirvixRow(`${dim("◐ Evaluating authorization...")}`)];
114
+ const stop = this.animate("Evaluating policy");
115
+ const parsed = parseRequest(text, { agent: this.agent });
116
+ let decision;
117
+ try {
118
+ ({ decision } = decideNow({ ...parsed, rules: this.rules, cwd: this.cwd }));
119
+ } finally {
120
+ stop();
121
+ }
122
+ const event = {
123
+ decision_id: `dec_local`,
124
+ request_id: `req_local`,
125
+ agent: this.agent,
126
+ tool: parsed.tool,
127
+ resource: parsed.args.path ?? parsed.args.url ?? parsed.args.command ?? "",
128
+ risk: decision.risk ?? "medium",
129
+ decision: decision.decision ?? "deny",
130
+ policy: decision.rule,
131
+ reason: decision.reason,
132
+ latency_ms: 0,
133
+ };
134
+ this.bus.emit(createEvent.policyDecision(event));
135
+ if (event.decision === "require_approval") this.bus.emit(createEvent.approvalRequested({ tool: event.tool, resource: event.resource }));
136
+ this.bus.emit(createEvent.agentMessage(`Decision: ${event.decision}`));
137
+
138
+ return [...lines, ``, this.renderDecision(event, parsed), ``, this.renderStatus()].join("\n");
139
+ }
140
+
141
+ renderDecision(event, parsed = {}) {
142
+ const d = event.decision;
143
+ if (d === "deny") {
144
+ const target = event.resource || parsed.args?.command || "";
145
+ return [blockedCard({ tool: event.tool, target, policy: event.policy, reason: event.reason }), ``, explainDecision(event)].join("\n");
146
+ }
147
+ if (d === "require_approval") {
148
+ return heldCard({ tool: event.tool, target: event.resource, approvers: event.approvers ?? [], reason: event.reason });
149
+ }
150
+ return [
151
+ policyCard({
152
+ action: event.tool,
153
+ risk: event.risk,
154
+ policy: event.policy ?? "default",
155
+ identity: `agent:${event.agent}`,
156
+ reason: event.reason,
157
+ latencyMs: event.latency_ms,
158
+ }),
159
+ ...(d === "sanitize" ? [``, explainDecision(event)] : []),
160
+ ].join("\n");
161
+ }
162
+
163
+ /* ---------------------------------------------------------------- slash */
164
+
165
+ async runSlash(input) {
166
+ const [cmd, ...rest] = input.trim().split(/\s+/);
167
+ const arg = rest.join(" ");
168
+ switch (cmd.toLowerCase()) {
169
+ case "/help":
170
+ return paletteBox("/");
171
+ case "/quit":
172
+ case "/exit":
173
+ return "quit";
174
+ case "/clear":
175
+ return "\x1b[2J\x1b[0;0H" + this.renderTranscript();
176
+ case "/expand":
177
+ this.expanded = true;
178
+ return this.renderActivity();
179
+ case "/collapse":
180
+ this.expanded = false;
181
+ return this.renderActivity();
182
+ case "/theme": {
183
+ if (!arg) return `Usage: /theme <${THEME_NAMES.join("|")}>\nCurrent: ${bold(process.env.CIRVIX_THEME ?? "dark")}`;
184
+ try {
185
+ setTheme(arg);
186
+ process.env.CIRVIX_THEME = arg;
187
+ return `${style("✓", "allow")} Theme → ${bold(arg)}`;
188
+ } catch (err) {
189
+ return `${style("✕", "block")} ${err.message}`;
190
+ }
191
+ }
192
+ case "/audit":
193
+ case "/logs":
194
+ return this.renderActivity();
195
+ case "/policies": {
196
+ const names = this.rules.map((r) => ` • ${r.name ?? "(unnamed)"} ${dim(r.effect ?? "")}`).join("\n");
197
+ return `${bold("Policies")} ${dim(`(${this.rules.length} rules)`)}\n${names || dim(" (no rules loaded)")}`;
198
+ }
199
+ case "/sessions":
200
+ return `${bold("Session")} ${this.state.session?.id ?? "—"} ${dim(`agent ${this.state.session?.agent ?? this.agent}`)}\n${dim(`${this.state.status.requests} requests this session`)}`;
201
+ case "/doctor":
202
+ return doctor(this.rules, this.state);
203
+ case "/demo":
204
+ return dim("Run `cirvix demo` in a fresh terminal for the full live interception demo.");
205
+ case "/approvals": {
206
+ if (!this.state.approvals.length) return dim("nothing waiting on a human");
207
+ return this.state.approvals.map((a) => ` ${bold(a.approval_id ?? a.id ?? "approval")} ${a.tool ?? ""} ${dim(a.resource ?? "")}`).join("\n");
208
+ }
209
+ case "/config":
210
+ return `${bold("Config")}\n ${dim("theme")} ${process.env.CIRVIX_THEME ?? "dark"}\n ${dim("agent")} ${this.agent}\n ${dim("mode")} ${this.state.status.mode}`;
211
+ default: {
212
+ // Prefix completion: "/pol" → show matches instead of erroring.
213
+ const { filterCommands } = await import("./palette.mjs");
214
+ const matches = filterCommands(input);
215
+ if (matches.length) return paletteBox(input);
216
+ return dim(`Unknown command "${cmd}". Type / for the palette.`);
217
+ }
218
+ }
219
+ }
220
+
221
+ /* ---------------------------------------------------------- interactive */
222
+
223
+ async start() {
224
+ this.write(this.renderTranscript() + "\n\n");
225
+ this.write(dim("Type / for commands. Ctrl+K palette · Ctrl+O activity · Esc close.\n"));
226
+ const rl = startComposer({
227
+ prompt: "> ",
228
+ onLine: async (line) => {
229
+ if (!line) return;
230
+ if (line === "/quit" || line === "/exit") return "quit";
231
+ const out = await this.runOnce(line);
232
+ if (out === "quit") return "quit";
233
+ this.write("\n" + out + "\n\n");
234
+ if (!this.narrow()) {
235
+ // Side-pane refresh on wide terminals: reprint compact activity.
236
+ this.write(dim("─ Activity ─") + "\n" + this.renderActivity() + "\n\n" + this.renderStatus() + "\n");
237
+ }
238
+ },
239
+ onKey: (key) => {
240
+ if (key === "toggle-activity") {
241
+ this.expanded = !this.expanded;
242
+ this.write("\n" + this.renderActivity() + "\n");
243
+ } else if (key === "palette") {
244
+ this.write("\n" + paletteBox("/") + "\n");
245
+ } else if (key === "policies") {
246
+ this.write("\n" + `Policies: ${this.rules.length} loaded` + "\n");
247
+ }
248
+ },
249
+ });
250
+ await new Promise((resolve) => rl.on("close", resolve));
251
+ this.bus.emit(createEvent.sessionEnded({}));
252
+ this.write("\n" + dim("Session ended. Audit chain intact.") + "\n");
253
+ }
254
+ }
255
+
256
+ /* ------------------------------------------------------------------ */
257
+ /* Helpers */
258
+ /* ------------------------------------------------------------------ */
259
+
260
+ /**
261
+ * Heuristic: turn "Can the deploy bot run `rm -rf /`?" into a policy
262
+ * request. Deliberately conservative — unknown shapes become a low-risk
263
+ * workspace read that policy will judge on its merits, never an implicit
264
+ * permit of something dangerous.
265
+ */
266
+ export function parseRequest(text, { agent = "local" } = {}) {
267
+ const t = String(text);
268
+ const url = t.match(/https?:\/\/[^\s"'`]+/i)?.[0];
269
+ const credPath = t.match(/(~\/\.aws\/credentials|\.env[^\s]*|\.aws[^\s]*)/i)?.[0];
270
+ const cmd = t.match(/`([^`]+)`/)?.[1] ?? t.match(/run\s+"([^"]+)"/i)?.[1];
271
+
272
+ if (credPath) return { tool: "read_file", server: null, args: { path: credPath }, agent };
273
+ if (url && /collect|attacker|exfil|send|post/i.test(t)) {
274
+ return { tool: "http_request", server: null, args: { url }, agent };
275
+ }
276
+ if (url) return { tool: "http_request", server: null, args: { url }, agent };
277
+ if (cmd) return { tool: "shell_exec", server: null, args: { command: cmd }, agent };
278
+ if (/deploy|production/i.test(t)) return { tool: "shell_exec", server: null, args: { command: "deploy production" }, agent };
279
+ if (/database|db|sql/i.test(t)) return { tool: "db_query", server: null, args: { sql: t.slice(0, 200) }, agent };
280
+ return { tool: "read_file", server: null, args: { path: "./" + t.slice(0, 60) }, agent };
281
+ }
282
+
283
+ function doctor(rules, state) {
284
+ const rows = [
285
+ ["Engine", style("✓ reachable", "allow")],
286
+ ["Rules", rules.length ? `${rules.length} loaded` : style("none — run cirvix init", "warning")],
287
+ ["Session", `${state.status.requests} requests`],
288
+ ["Blocked", state.status.blocked > 0 ? style(`${state.status.blocked} denied`, "block") : "0"],
289
+ ["Terminal", `${process.stdout.columns ?? 80} cols ${process.stdout.isTTY ? "(TTY)" : "(piped)"}`],
290
+ ];
291
+ return `${bold("Doctor")}\n` + rows.map(([k, v]) => ` ${dim(k.padEnd(9))} ${v}`).join("\n");
292
+ }