actoviq-agent-sdk 0.4.2 → 0.4.6

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.
Files changed (116) hide show
  1. package/README-zh.md +58 -62
  2. package/README.md +72 -22
  3. package/assets/actoviq-icon.png +0 -0
  4. package/bin/actoviq-gui.js +24 -0
  5. package/dist/src/cli/actoviq-react.js +31 -14
  6. package/dist/src/cli/actoviq-react.js.map +1 -1
  7. package/dist/src/cli/actoviq-tui.js +9 -0
  8. package/dist/src/cli/actoviq-tui.js.map +1 -1
  9. package/dist/src/cli/bridge-interactive-agent.js +94 -15
  10. package/dist/src/cli/bridge-interactive-agent.js.map +1 -1
  11. package/dist/src/cli/version.d.ts +3 -0
  12. package/dist/src/cli/version.d.ts.map +1 -0
  13. package/dist/src/cli/version.js +28 -0
  14. package/dist/src/cli/version.js.map +1 -0
  15. package/dist/src/gui/actoviqGui.d.ts +29 -0
  16. package/dist/src/gui/actoviqGui.d.ts.map +1 -0
  17. package/dist/src/gui/actoviqGui.js +5319 -0
  18. package/dist/src/gui/actoviqGui.js.map +1 -0
  19. package/dist/src/gui/electronMain.d.ts +3 -0
  20. package/dist/src/gui/electronMain.d.ts.map +1 -0
  21. package/dist/src/gui/electronMain.js +176 -0
  22. package/dist/src/gui/electronMain.js.map +1 -0
  23. package/dist/src/gui/guiMarkdown.d.ts +3 -0
  24. package/dist/src/gui/guiMarkdown.d.ts.map +1 -0
  25. package/dist/src/gui/guiMarkdown.js +188 -0
  26. package/dist/src/gui/guiMarkdown.js.map +1 -0
  27. package/dist/src/hooks/userHooks.d.ts +39 -0
  28. package/dist/src/hooks/userHooks.d.ts.map +1 -0
  29. package/dist/src/hooks/userHooks.js +169 -0
  30. package/dist/src/hooks/userHooks.js.map +1 -0
  31. package/dist/src/index.d.ts +2 -0
  32. package/dist/src/index.d.ts.map +1 -1
  33. package/dist/src/index.js +2 -0
  34. package/dist/src/index.js.map +1 -1
  35. package/dist/src/mcp/mcpServerConfig.d.ts +19 -0
  36. package/dist/src/mcp/mcpServerConfig.d.ts.map +1 -0
  37. package/dist/src/mcp/mcpServerConfig.js +51 -0
  38. package/dist/src/mcp/mcpServerConfig.js.map +1 -0
  39. package/dist/src/memory/projectContext.d.ts +32 -0
  40. package/dist/src/memory/projectContext.d.ts.map +1 -0
  41. package/dist/src/memory/projectContext.js +175 -0
  42. package/dist/src/memory/projectContext.js.map +1 -0
  43. package/dist/src/memory/sessionHistory.d.ts +21 -0
  44. package/dist/src/memory/sessionHistory.d.ts.map +1 -0
  45. package/dist/src/memory/sessionHistory.js +83 -0
  46. package/dist/src/memory/sessionHistory.js.map +1 -0
  47. package/dist/src/parity/actoviqBridgeSdk.d.ts +3 -0
  48. package/dist/src/parity/actoviqBridgeSdk.d.ts.map +1 -1
  49. package/dist/src/parity/actoviqBridgeSdk.js +93 -92
  50. package/dist/src/parity/actoviqBridgeSdk.js.map +1 -1
  51. package/dist/src/parity/bridgeConfigs.d.ts +49 -0
  52. package/dist/src/parity/bridgeConfigs.d.ts.map +1 -0
  53. package/dist/src/parity/bridgeConfigs.js +134 -0
  54. package/dist/src/parity/bridgeConfigs.js.map +1 -0
  55. package/dist/src/parity/bridgeEventAdapter.d.ts +20 -0
  56. package/dist/src/parity/bridgeEventAdapter.d.ts.map +1 -0
  57. package/dist/src/parity/bridgeEventAdapter.js +121 -0
  58. package/dist/src/parity/bridgeEventAdapter.js.map +1 -0
  59. package/dist/src/parity/bridgeExecResolver.d.ts +27 -0
  60. package/dist/src/parity/bridgeExecResolver.d.ts.map +1 -0
  61. package/dist/src/parity/bridgeExecResolver.js +95 -0
  62. package/dist/src/parity/bridgeExecResolver.js.map +1 -0
  63. package/dist/src/parity/bridgeProviders.d.ts +132 -0
  64. package/dist/src/parity/bridgeProviders.d.ts.map +1 -0
  65. package/dist/src/parity/bridgeProviders.js +651 -0
  66. package/dist/src/parity/bridgeProviders.js.map +1 -0
  67. package/dist/src/prompts/outputStyles.d.ts +20 -0
  68. package/dist/src/prompts/outputStyles.d.ts.map +1 -0
  69. package/dist/src/prompts/outputStyles.js +35 -0
  70. package/dist/src/prompts/outputStyles.js.map +1 -0
  71. package/dist/src/prompts/systemPrompt.d.ts.map +1 -1
  72. package/dist/src/prompts/systemPrompt.js +9 -1
  73. package/dist/src/prompts/systemPrompt.js.map +1 -1
  74. package/dist/src/router/modelRouter.d.ts +7 -1
  75. package/dist/src/router/modelRouter.d.ts.map +1 -1
  76. package/dist/src/router/modelRouter.js +50 -15
  77. package/dist/src/router/modelRouter.js.map +1 -1
  78. package/dist/src/runtime/agentSession.d.ts +6 -0
  79. package/dist/src/runtime/agentSession.d.ts.map +1 -1
  80. package/dist/src/runtime/agentSession.js +15 -0
  81. package/dist/src/runtime/agentSession.js.map +1 -1
  82. package/dist/src/runtime/bashClassification.d.ts +18 -0
  83. package/dist/src/runtime/bashClassification.d.ts.map +1 -0
  84. package/dist/src/runtime/bashClassification.js +83 -0
  85. package/dist/src/runtime/bashClassification.js.map +1 -0
  86. package/dist/src/team/modelTeam.d.ts +3 -5
  87. package/dist/src/team/modelTeam.d.ts.map +1 -1
  88. package/dist/src/team/modelTeam.js +98 -162
  89. package/dist/src/team/modelTeam.js.map +1 -1
  90. package/dist/src/team/teamRuntime.d.ts +60 -0
  91. package/dist/src/team/teamRuntime.d.ts.map +1 -0
  92. package/dist/src/team/teamRuntime.js +139 -0
  93. package/dist/src/team/teamRuntime.js.map +1 -0
  94. package/dist/src/tools/actoviqCoreTools.d.ts +1 -0
  95. package/dist/src/tools/actoviqCoreTools.d.ts.map +1 -1
  96. package/dist/src/tools/actoviqCoreTools.js +5 -0
  97. package/dist/src/tools/actoviqCoreTools.js.map +1 -1
  98. package/dist/src/tools/planMode/PlanModeTools.d.ts +18 -0
  99. package/dist/src/tools/planMode/PlanModeTools.d.ts.map +1 -0
  100. package/dist/src/tools/planMode/PlanModeTools.js +71 -0
  101. package/dist/src/tools/planMode/PlanModeTools.js.map +1 -0
  102. package/dist/src/tui/actoviqTui.d.ts +3 -0
  103. package/dist/src/tui/actoviqTui.d.ts.map +1 -1
  104. package/dist/src/tui/actoviqTui.js +1474 -91
  105. package/dist/src/tui/actoviqTui.js.map +1 -1
  106. package/dist/src/tui/transcript.d.ts +6 -0
  107. package/dist/src/tui/transcript.d.ts.map +1 -1
  108. package/dist/src/tui/transcript.js +27 -0
  109. package/dist/src/tui/transcript.js.map +1 -1
  110. package/dist/src/types.d.ts +128 -9
  111. package/dist/src/types.d.ts.map +1 -1
  112. package/dist/src/ui/commandSurface.d.ts +13 -0
  113. package/dist/src/ui/commandSurface.d.ts.map +1 -0
  114. package/dist/src/ui/commandSurface.js +100 -0
  115. package/dist/src/ui/commandSurface.js.map +1 -0
  116. package/package.json +42 -3
@@ -5,20 +5,32 @@
5
5
  * output, permission dialogs, and mid-run steering. Dependency-free ANSI
6
6
  * rendering (no React/Ink).
7
7
  */
8
- import { execSync } from 'node:child_process';
8
+ import { execSync, spawnSync } from 'node:child_process';
9
9
  import fs from 'node:fs';
10
+ import os from 'node:os';
10
11
  import path from 'node:path';
11
12
  import * as readline from 'node:readline';
12
- import { createActoviqCoreTools, createAgentSdk, loadDefaultActoviqSettings, loadJsonConfigFile, listWorkflows, loadWorkflow, listTeamDefinitions, loadTeamDefinition, createModelTeam, createTeamTool, listRouterProfiles, loadRouterProfile, resolveRoutedRun, WorktreeService, } from '../index.js';
13
+ import { createActoviqCoreTools, createAgentSdk, detectBridgeProviders, loadDefaultActoviqSettings, loadJsonConfigFile, listWorkflows, loadWorkflow, listTeamDefinitions, loadTeamDefinition, createModelTeam, createTeamTool, listRouterProfiles, loadRouterProfile, resolveRoutedRun, WorktreeService, } from '../index.js';
13
14
  import { persistActoviqSettingsStore, resolveActoviqSettingsStore, } from '../config/actoviqSettingsStore.js';
15
+ import { createPreToolUseHookClassifier, readPreToolUseHooks, readPostToolUseHooks, runPostToolUseHooks, readSessionStartHooks, runSessionStartHooks } from '../hooks/userHooks.js';
14
16
  import { isRecord } from '../runtime/helpers.js';
17
+ import { getLoadedJsonConfig } from '../config/loadJsonConfigFile.js';
18
+ import { findBridgeConfig, maskApiKey, readBridgeConfigs, addBridgeConfig, removeBridgeConfig, } from '../parity/bridgeConfigs.js';
19
+ import { buildRouteModelApi } from '../router/modelRouter.js';
20
+ import { addMcpServer, readMcpServerConfig, removeMcpServer } from '../mcp/mcpServerConfig.js';
21
+ import { isReadOnlyBashCommand } from '../runtime/bashClassification.js';
22
+ import { estimateCost } from '../team/pricing.js';
23
+ import { applyOutputStyle, OUTPUT_STYLES } from '../prompts/outputStyles.js';
24
+ import { planFilePath, readPlanFile } from '../tools/planMode/PlanModeTools.js';
25
+ import { loadProjectContext } from '../memory/projectContext.js';
15
26
  import { pathToFileURL } from 'node:url';
27
+ import { ACTOVIQ_INTERACTIVE_COMMANDS, SUBCOMMAND_DESCRIPTIONS, filterInteractiveCommands, } from '../ui/commandSurface.js';
16
28
  import { A, stringWidth, truncateToWidth, wrapToWidth } from './ansi.js';
17
29
  import { InputEditor } from './editor.js';
18
30
  import { discoverActoviqPlugins } from './pluginCatalog.js';
19
31
  import { TuiScreen } from './screen.js';
20
32
  import { filterTuiSelectionItems, moveTuiSelection, } from './selection.js';
21
- import { StreamFlusher, formatBanner, formatCompactNotice, formatErrorLine, formatInfoLine, formatQueuedPrompt, formatThinking, formatToolCall, formatToolResult, formatUserPrompt, } from './transcript.js';
33
+ import { StreamFlusher, formatBanner, formatCompactNotice, formatDivider, formatEditCall, formatErrorLine, formatInfoLine, formatQueuedPrompt, formatThinking, formatToolCall, formatToolResult, formatUserPrompt, } from './transcript.js';
22
34
  const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
23
35
  const CTRL_C_EXIT_WINDOW_MS = 600;
24
36
  const DYNAMIC_FRAME_MS = 33;
@@ -35,35 +47,15 @@ const PERMISSION_MODES = new Set([
35
47
  'bypassPermissions',
36
48
  'auto',
37
49
  ]);
38
- export const TUI_SLASH_COMMANDS = {
39
- help: 'Show available commands',
40
- clear: 'Clear the screen',
41
- compact: 'Compact the current session',
42
- memory: 'Show memory/compact state',
43
- model: 'Select a model or configure its provider',
44
- effort: 'Select the reasoning effort',
45
- permissions: 'Show or set the permission mode',
46
- sessions: 'List stored sessions',
47
- resume: 'Resume a stored session',
48
- tools: 'List available tools',
49
- skills: 'Browse available skills',
50
- agents: 'Browse available subagents',
51
- mcp: 'Inspect MCP servers and tools',
52
- plugins: 'Browse discovered Clean plugins',
53
- dream: 'Inspect or run memory consolidation',
54
- workflows: 'Browse saved dynamic workflows',
55
- worktree: 'Enter, exit, or list git worktrees',
56
- team: 'List or run Model Team definitions',
57
- exit: 'Quit',
58
- };
50
+ export const TUI_SLASH_COMMANDS = ACTOVIQ_INTERACTIVE_COMMANDS;
51
+ /** Mask an API key for display: show first 4 + last 4, hide the middle. */
52
+ function maskKey(key) {
53
+ if (key.length <= 8)
54
+ return '****';
55
+ return `${key.slice(0, 4)}…${key.slice(-4)}`;
56
+ }
59
57
  export function filterSlashCommands(input) {
60
- if (!input.startsWith('/'))
61
- return [];
62
- const head = input.slice(1).split(/\s/, 1)[0] ?? '';
63
- if (input.includes(' ') && head.length > 0)
64
- return [];
65
- const partial = head.toLowerCase();
66
- return Object.keys(TUI_SLASH_COMMANDS).filter((name) => name.startsWith(partial));
58
+ return filterInteractiveCommands(input);
67
59
  }
68
60
  /**
69
61
  * Detect an active "@file" mention at the cursor for path completion. Returns
@@ -91,10 +83,32 @@ export function activeAtToken(text, cursor) {
91
83
  * expert-panel member reports so long output reads cleanly instead of dumping
92
84
  * raw lines. Optionally caps very long output with a "… (N more lines)" note.
93
85
  */
94
- function renderRichText(text, width, opts = {}) {
86
+ // Apply inline markdown formatting to a single unwrapped line segment:
87
+ // `code` → dim, **bold** → bold, *italic* → italic. Applied after wrapping
88
+ // so ANSI codes never split mid-segment.
89
+ function renderMarkdownInline(line) {
90
+ return line
91
+ .replace(/`([^`]+)`/g, `${A.dim}$1${A.reset}`)
92
+ .replace(/\*\*([^*]+)\*\*/g, `${A.bold}$1${A.reset}`)
93
+ .replace(/\*([^*]+)\*/g, `${A.italic}$1${A.reset}`);
94
+ }
95
+ export function renderRichText(text, width, opts = {}) {
95
96
  const cols = Math.max(20, width - 2);
96
97
  const out = [];
98
+ let inFence = false;
97
99
  for (const raw of text.replace(/\r/g, '').split('\n')) {
100
+ // Fenced code blocks: render a dim rule at the fence and the content
101
+ // dim/gray so code reads as a block instead of raw markdown text. Code
102
+ // lines are not word-wrapped (wrapping would corrupt code).
103
+ if (/^\s*```/.test(raw)) {
104
+ inFence = !inFence;
105
+ out.push(`${A.dim}${'─'.repeat(Math.min(cols, 40))}${A.reset}`);
106
+ continue;
107
+ }
108
+ if (inFence) {
109
+ out.push(`${A.gray} ${raw}${A.reset}`);
110
+ continue;
111
+ }
98
112
  const heading = /^(#{1,6})\s+(.*)$/.exec(raw);
99
113
  if (heading) {
100
114
  out.push(`${A.bold}${A.cyan}${heading[2]}${A.reset}`);
@@ -104,8 +118,10 @@ function renderRichText(text, width, opts = {}) {
104
118
  out.push('');
105
119
  continue;
106
120
  }
121
+ // Wrap first, then apply inline formatting per segment — avoids ANSI
122
+ // splitting by never putting styling codes mid-segment.
107
123
  for (const line of wrapToWidth(raw, cols))
108
- out.push(line);
124
+ out.push(renderMarkdownInline(line));
109
125
  }
110
126
  const maxLines = opts.maxLines ?? 0;
111
127
  if (maxLines > 0 && out.length > maxLines) {
@@ -124,6 +140,12 @@ function buildSystemPrompt(workDir) {
124
140
  catch {
125
141
  // not a git repo
126
142
  }
143
+ // Load the CLAUDE.md hierarchy (user + project, with @includes) so the agent
144
+ // picks up project-specific instructions — the canonical Claude Code behavior.
145
+ const project = loadProjectContext(workDir);
146
+ const projectSection = project.text
147
+ ? `\n\n# Project context (CLAUDE.md)\n\nThe following project instructions were loaded from CLAUDE.md files. Treat them as authoritative guidance for this workspace.\n\n${project.text}\n`
148
+ : '';
127
149
  return (`You are Hadamard Agent, an interactive CLI agent. Working directory: ${workDir}\n\n` +
128
150
  `<env>\nWorking directory: ${workDir}\nIs git repo: ${isGit ? 'Yes' : 'No'}\nPlatform: ${process.platform}\nDate: ${new Date().toISOString().slice(0, 10)}\n</env>\n\n` +
129
151
  `# Tone and style\n` +
@@ -141,7 +163,36 @@ function buildSystemPrompt(workDir) {
141
163
  `- NEVER commit changes unless the user explicitly asks you to\n\n` +
142
164
  `# Other\n` +
143
165
  `- NEVER create documentation files (*.md) unless explicitly requested.\n` +
144
- `- When in doubt, use TodoWrite to track progress.`);
166
+ `- When in doubt, use TodoWrite to track progress.`) + projectSection;
167
+ }
168
+ // First-run onboarding: guides the user through creating ~/.actoviq/settings.json
169
+ // when no credential is found. Uses plain readline (no TTY required beyond stdin).
170
+ async function onboardCredentials(configPath) {
171
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
172
+ const ask = (q) => new Promise(r => rl.question(q, r));
173
+ console.log('\n Welcome to Actoviq! Let\'s set up your first connection.\n');
174
+ const provider = await ask(' Provider (anthropic/openai) [anthropic]: ');
175
+ const apiKey = await ask(' API Key: ');
176
+ const baseURL = await ask(' Base URL [https://api.deepseek.com]: ');
177
+ const model = await ask(' Model [deepseek-chat]: ');
178
+ rl.close();
179
+ const resolvedProvider = provider.trim() || 'anthropic';
180
+ const resolvedBaseURL = baseURL.trim() || 'https://api.deepseek.com';
181
+ const resolvedModel = model.trim() || 'deepseek-chat';
182
+ const home = os.homedir();
183
+ const dir = path.join(home, '.actoviq');
184
+ const file = configPath ?? path.join(dir, 'settings.json');
185
+ fs.mkdirSync(dir, { recursive: true });
186
+ const env = {
187
+ ACTOVIQ_API_KEY: apiKey.trim(),
188
+ ACTOVIQ_BASE_URL: resolvedBaseURL,
189
+ ACTOVIQ_MODEL: resolvedModel,
190
+ };
191
+ if (resolvedProvider === 'openai')
192
+ env.ACTOVIQ_PROVIDER = 'openai';
193
+ const settings = { env };
194
+ fs.writeFileSync(file, JSON.stringify(settings, null, 2), 'utf-8');
195
+ console.log(`\n Config saved to ${file}. Starting TUI...\n`);
145
196
  }
146
197
  export async function runActoviqTui(options = {}) {
147
198
  const workDir = path.resolve(options.workDir ?? process.cwd());
@@ -163,10 +214,56 @@ export async function runActoviqTui(options = {}) {
163
214
  workDir,
164
215
  tools,
165
216
  permissionMode,
217
+ // Load user-managed stdio MCP servers from ~/.actoviq/mcp.json (gap #10).
218
+ mcpServers: readMcpServerConfig().servers.map(s => (s.command
219
+ ? { kind: 'stdio', name: s.name, command: s.command, ...(s.args ? { args: s.args } : {}), ...(s.env ? { env: s.env } : {}), ...(s.cwd ? { cwd: s.cwd } : {}) }
220
+ : { kind: 'streamable_http', name: s.name, url: s.url, ...(s.headers ? { headers: s.headers } : {}) })),
166
221
  ...(options.model ? { model: options.model } : {}),
167
222
  });
168
- let sdk = await createCleanSdk();
169
- let toolMetadata = await sdk.listToolMetadata();
223
+ let sdk;
224
+ let toolMetadata;
225
+ while (true) {
226
+ try {
227
+ sdk = await createCleanSdk();
228
+ toolMetadata = await sdk.listToolMetadata();
229
+ break;
230
+ }
231
+ catch (error) {
232
+ if (error instanceof Error && error.message.includes('No Actoviq credential')) {
233
+ // First-run onboarding: guide the user through creating ~/.actoviq/settings.json.
234
+ await onboardCredentials(options.configPath);
235
+ // After saving, retry SDK creation.
236
+ continue;
237
+ }
238
+ throw error;
239
+ }
240
+ }
241
+ // Build a dynamic capabilities section injected into the system prompt each
242
+ // turn (gap #16 vs claude-code) — subagents, MCP servers+tools, skills — so
243
+ // the model knows what it can delegate to/use beyond the core tool list.
244
+ function buildAgentContext() {
245
+ const parts = [];
246
+ const agents = sdk.listAgentDefinitions();
247
+ if (agents.length > 0) {
248
+ parts.push(`Available subagents: ${agents.map(a => a.name).join(', ')}`);
249
+ }
250
+ const byServer = new Map();
251
+ for (const tool of toolMetadata.filter(item => item.provider === 'mcp')) {
252
+ const server = tool.server ?? 'mcp';
253
+ if (!byServer.has(server))
254
+ byServer.set(server, []);
255
+ byServer.get(server).push(tool);
256
+ }
257
+ for (const [server, tools] of byServer) {
258
+ const names = tools.map(t => t.name).slice(0, 12).join(', ');
259
+ parts.push(`MCP server "${server}": ${names}${tools.length > 12 ? '…' : ''}`);
260
+ }
261
+ const skills = sdk.skills.listMetadata();
262
+ if (skills.length > 0) {
263
+ parts.push(`Skills: ${skills.map(s => s.name).slice(0, 12).join(', ')}${skills.length > 12 ? '…' : ''}`);
264
+ }
265
+ return parts.length > 0 ? `\n\n# Available capabilities\n\n${parts.join('\n')}\n` : '';
266
+ }
170
267
  let session = options.resumeSessionId
171
268
  ? await sdk.resumeSession(options.resumeSessionId, {
172
269
  model: options.model,
@@ -182,6 +279,8 @@ export async function runActoviqTui(options = {}) {
182
279
  model: options.model,
183
280
  permissionMode,
184
281
  });
282
+ // Run SessionStart hooks (fire-and-forget, from settings.json hooks.SessionStart[]).
283
+ runSessionStartHooks(() => readSessionStartHooks(getLoadedJsonConfig()?.raw), sdk.config.workDir);
185
284
  const screen = new TuiScreen(process.stdout);
186
285
  const editor = new InputEditor();
187
286
  const flusher = new StreamFlusher(() => screen.width);
@@ -194,6 +293,19 @@ export async function runActoviqTui(options = {}) {
194
293
  // /model router: when set, each user turn is classified and routed to a model.
195
294
  let activeRouter = null;
196
295
  let routedModelLabel = null;
296
+ // Bridge mode: when true, prompts run in-process through the selected config's
297
+ // provider/apiKey/baseURL/model (no child process). The active config's
298
+ // credentials are pre-built into a RoutedModel via buildRouteModelApi, then
299
+ // injected per-run into session.stream({model, modelApi}) — same session,
300
+ // context naturally survives switching bridge↔hadamard.
301
+ let bridgeMode = false;
302
+ let activeBridgeConfig = null;
303
+ // Pre-built {model, modelApi} for the active config. Built once at activation;
304
+ // stale after disable (cleared). Per-run injection reuses the /model router's
305
+ // proven mechanism (session.stream({model, modelApi})).
306
+ let activeBridgeModelApi = null;
307
+ // Display labels (model is set from the config on activation).
308
+ let bridgeModelLabel = null;
197
309
  let abortCtrl = null;
198
310
  let dialog = null;
199
311
  let selectionDialog = null;
@@ -208,10 +320,48 @@ export async function runActoviqTui(options = {}) {
208
320
  let runStartedAt = 0;
209
321
  let runToolCount = 0;
210
322
  let lastTokenEstimate;
323
+ // Running token + USD totals for /cost and /usage. Per-config breakdown
324
+ // shows spend by each bridge config so the user can compare backends.
325
+ let totalInputTokens = 0;
326
+ let totalOutputTokens = 0;
327
+ let totalCostUsd = 0;
328
+ const configUsage = new Map();
329
+ function recordUsage(model, usage) {
330
+ const inT = usage?.input_tokens ?? 0;
331
+ const outT = usage?.output_tokens ?? 0;
332
+ totalInputTokens += inT;
333
+ totalOutputTokens += outT;
334
+ const cost = estimateCost(model, inT, outT);
335
+ totalCostUsd = cost === null ? null : (totalCostUsd === null ? cost : totalCostUsd + cost);
336
+ // Per-config tracking: attribute this turn to the active bridge config.
337
+ if (bridgeMode && activeBridgeConfig) {
338
+ const rec = configUsage.get(activeBridgeConfig.name) ?? { inputTokens: 0, outputTokens: 0, turns: 0 };
339
+ rec.inputTokens += inT;
340
+ rec.outputTokens += outT;
341
+ rec.turns += 1;
342
+ configUsage.set(activeBridgeConfig.name, rec);
343
+ }
344
+ }
345
+ function configCost(name, rec) {
346
+ const cfg = findBridgeConfig(name);
347
+ if (!cfg?.model)
348
+ return null;
349
+ const cost = estimateCost(cfg.model, rec.inputTokens, rec.outputTokens);
350
+ return cost !== null ? `$${cost.toFixed(4)}` : null;
351
+ }
211
352
  let statusNote = '';
353
+ // /output-style prompt prefix swap (gap #19). Applied to the base system
354
+ // prompt per turn; 'default' is a no-op.
355
+ let outputStyle = 'default';
212
356
  let ctrlCCount = 0;
213
357
  let ctrlCTimer = null;
214
358
  let streamedTextSeen = false;
359
+ // Track tool names by callId for PostToolUse hook context.
360
+ const toolCallNames = new Map();
361
+ // Live todo list (captured from TodoWrite tool calls). Rendered as a
362
+ // persistent panel in the dynamic region so the user can see what the agent
363
+ // is working on / what remains — Claude Code's main progress affordance.
364
+ let currentTodos = [];
215
365
  const queuedInputs = [];
216
366
  const currentPermissionMode = () => session.permissionContext.mode ?? permissionMode;
217
367
  const currentEffort = () => {
@@ -284,20 +434,21 @@ export async function runActoviqTui(options = {}) {
284
434
  });
285
435
  dialog = null;
286
436
  renderDynamic();
287
- if (outcome === 'always') {
437
+ if (outcome === 'always' || outcome === 'always-user') {
288
438
  const state = session.permissionContext;
289
439
  const permissions = state.permissions.filter(rule => !(rule.toolName === context.publicName && rule.behavior === 'allow'));
440
+ const source = outcome === 'always-user' ? 'user' : 'project';
290
441
  permissions.push({
291
442
  toolName: context.publicName,
292
443
  behavior: 'allow',
293
- source: 'tui-session',
444
+ source,
294
445
  });
295
446
  await session.setPermissionContext({
296
447
  mode: state.mode ?? permissionMode,
297
448
  permissions,
298
449
  approver,
299
450
  });
300
- return { behavior: 'allow', reason: 'Approved (always) in TUI.' };
451
+ return { behavior: 'allow', reason: `Approved (always — ${source} scope) in TUI.` };
301
452
  }
302
453
  return outcome === 'allow'
303
454
  ? { behavior: 'allow', reason: 'Approved in TUI.' }
@@ -305,12 +456,28 @@ export async function runActoviqTui(options = {}) {
305
456
  };
306
457
  const canUseTool = permissionMode === 'default'
307
458
  ? (context) => {
459
+ if (context.publicName === 'Bash') {
460
+ // Auto-allow read-only commands (ls, git status, cat, …) so the
461
+ // default mode isn't a prompt on every harmless call (gap #12 vs
462
+ // claude-code). Everything else still prompts. isReadOnlyBashCommand
463
+ // is conservative — anything ambiguous falls through to 'ask'.
464
+ const command = context.input?.command;
465
+ if (typeof command === 'string' && isReadOnlyBashCommand(command)) {
466
+ return undefined;
467
+ }
468
+ return { behavior: 'ask', reason: 'Bash command may modify the workspace.' };
469
+ }
308
470
  if (MUTATING_TOOLS.has(context.publicName)) {
309
471
  return { behavior: 'ask', reason: `${context.publicName} mutates the workspace.` };
310
472
  }
311
473
  return undefined;
312
474
  }
313
475
  : undefined;
476
+ // User-configurable PreToolUse hooks from settings.json hooks.PreToolUse[].
477
+ // Lazily reads the live settings so edits are picked up without a restart.
478
+ // The classifier returns undefined (no-op) when no hooks match — so the run
479
+ // path is unchanged for users who haven't configured any.
480
+ const preToolUseHookClassifier = createPreToolUseHookClassifier(() => readPreToolUseHooks(getLoadedJsonConfig()?.raw));
314
481
  function summarizeForDialog(input) {
315
482
  if (typeof input !== 'object' || input === null)
316
483
  return '';
@@ -434,17 +601,35 @@ export async function runActoviqTui(options = {}) {
434
601
  const query = token.toLowerCase();
435
602
  if (!query)
436
603
  return files.slice(0, 200);
437
- const matches = files.filter((file) => file.toLowerCase().includes(query));
438
- matches.sort((a, b) => {
439
- const aBase = a.slice(a.lastIndexOf('/') + 1).toLowerCase();
440
- const bBase = b.slice(b.lastIndexOf('/') + 1).toLowerCase();
441
- const aScore = aBase.startsWith(query) ? 0 : aBase.includes(query) ? 1 : 2;
442
- const bScore = bBase.startsWith(query) ? 0 : bBase.includes(query) ? 1 : 2;
443
- if (aScore !== bScore)
444
- return aScore - bScore;
445
- return a.length - b.length;
446
- });
447
- return matches.slice(0, 200);
604
+ // Subsequence fuzzy match with path-aware scoring: prefer basename hits,
605
+ // then prefix hits, then path depth (shorter = nearer the root = more
606
+ // likely the file the user wants). Falls back to substring for short
607
+ // tokens so exact-include still ranks well.
608
+ const scored = [];
609
+ for (const file of files) {
610
+ const lower = file.toLowerCase();
611
+ const slash = lower.lastIndexOf('/') + 1;
612
+ const base = lower.slice(slash);
613
+ let score = -1;
614
+ if (base.startsWith(query))
615
+ score = 1000 - base.length;
616
+ else if (lower.includes(query))
617
+ score = 800 - slash;
618
+ else {
619
+ // Subsequence fuzzy match: walk the path consuming the query in order.
620
+ let qi = 0;
621
+ for (let i = 0; i < lower.length && qi < query.length; i++) {
622
+ if (lower[i] === query[qi])
623
+ qi++;
624
+ }
625
+ if (qi === query.length)
626
+ score = 400 - slash - (lower.length - query.length) * 0.1;
627
+ }
628
+ if (score >= 0)
629
+ scored.push({ file, score });
630
+ }
631
+ scored.sort((a, b) => b.score - a.score);
632
+ return scored.slice(0, 200).map((s) => s.file);
448
633
  }
449
634
  function buildAtMenu() {
450
635
  const active = activeAtToken(editor.text, editor.cursor);
@@ -513,7 +698,7 @@ export async function runActoviqTui(options = {}) {
513
698
  const selected = index === menuSelected;
514
699
  const command = `/${name}`.padEnd(commandWidth);
515
700
  const label = selected ? `${A.inverse}${command}${A.reset}` : command;
516
- const description = truncateToWidth(TUI_SLASH_COMMANDS[name] ?? '', descriptionWidth);
701
+ const description = truncateToWidth(TUI_SLASH_COMMANDS[name] ?? SUBCOMMAND_DESCRIPTIONS[name] ?? '', descriptionWidth);
517
702
  return `${label} ${A.dim}${description}${A.reset}`;
518
703
  });
519
704
  const hiddenAbove = windowStart;
@@ -532,7 +717,7 @@ export async function runActoviqTui(options = {}) {
532
717
  if (!dialog)
533
718
  return [];
534
719
  const inner = Math.max(screen.width - 4, 8);
535
- const options = ['Yes', `Yes, always allow ${dialog.toolName}`, 'No (esc)'];
720
+ const options = ['Yes', `Always ${dialog.toolName} (project)`, `Always ${dialog.toolName} (user)`, 'No (esc)'];
536
721
  const lines = [];
537
722
  lines.push(boxTop(A.yellow));
538
723
  lines.push(boxRow(`${A.bold}Permission required · ${dialog.toolName}${A.reset}`, A.yellow));
@@ -542,7 +727,7 @@ export async function runActoviqTui(options = {}) {
542
727
  lines.push(boxRow(selected ? `${A.inverse} ${option} ${A.reset}` : ` ${option}`, A.yellow));
543
728
  });
544
729
  lines.push(boxBottom(A.yellow));
545
- lines.push(`${A.dim} y/enter approve · a always · n/esc deny · ↑↓ select${A.reset}`);
730
+ lines.push(`${A.dim} y/enter approve · a always (project) · n/esc deny · ↑↓ select${A.reset}`);
546
731
  return lines;
547
732
  }
548
733
  function buildSelectionDialog() {
@@ -622,7 +807,10 @@ export async function runActoviqTui(options = {}) {
622
807
  const modelLabel = activeRouter
623
808
  ? `router:${activeRouter.name}${routedModelLabel ? ` → ${routedModelLabel}` : ''}`
624
809
  : session.model;
625
- const left = `${modelLabel} · ${permissionLabel()} · effort:${currentEffort() ?? 'auto'} · team:${activeTeamName ?? 'none'} · `;
810
+ const bridgeTag = bridgeMode && activeBridgeConfig
811
+ ? ` · bridge:${activeBridgeConfig.name}${bridgeModelLabel ? ` · ${bridgeModelLabel}` : ''}`
812
+ : '';
813
+ const left = `${modelLabel} · ${permissionLabel()} · effort:${currentEffort() ?? 'auto'} · team:${activeTeamName ?? 'none'}${bridgeTag}${goalContextLine()} · `;
626
814
  return `${A.dim} ${left}${A.reset}${ctxColor}ctx ${pct}% (${usedK})${A.reset}`;
627
815
  }
628
816
  function buildStatusLine() {
@@ -644,6 +832,39 @@ export async function runActoviqTui(options = {}) {
644
832
  }
645
833
  return [`${A.dim} ? shortcuts · / commands · @ files · \\↵ newline · ↑↓ history · ctrl+c clear/exit${A.reset}`];
646
834
  }
835
+ function buildTodoPanel() {
836
+ if (currentTodos.length === 0)
837
+ return [];
838
+ const max = 8;
839
+ const visible = currentTodos.slice(0, max);
840
+ const done = currentTodos.filter(t => t.status === 'completed').length;
841
+ const lines = [
842
+ `${A.dim} tasks (${done}/${currentTodos.length})${A.reset}`,
843
+ ];
844
+ for (const t of visible) {
845
+ let mark;
846
+ let body;
847
+ if (t.status === 'completed') {
848
+ mark = `${A.green}✓${A.reset}`;
849
+ body = `${A.dim}${truncateToWidth(t.content, screen.width - 6)}${A.reset}`;
850
+ }
851
+ else if (t.status === 'in_progress') {
852
+ mark = `${A.cyan}▶${A.reset}`;
853
+ // Show the present-continuous form while a task is actively executing.
854
+ const text = t.activeForm ?? t.content;
855
+ body = `${A.bold}${truncateToWidth(text, screen.width - 6)}${A.reset}`;
856
+ }
857
+ else {
858
+ mark = `${A.dim}○${A.reset}`;
859
+ body = `${truncateToWidth(t.content, screen.width - 6)}`;
860
+ }
861
+ lines.push(` ${mark} ${body}`);
862
+ }
863
+ const more = currentTodos.length - max;
864
+ if (more > 0)
865
+ lines.push(`${A.dim} … ${more} more${A.reset}`);
866
+ return lines;
867
+ }
647
868
  function renderDynamic() {
648
869
  const lines = [];
649
870
  lines.push(...buildStatusLine());
@@ -651,6 +872,11 @@ export async function runActoviqTui(options = {}) {
651
872
  if (running && tail) {
652
873
  lines.push(tail);
653
874
  }
875
+ // Live todo panel — shown whenever the agent has a plan, unless a modal
876
+ // (permission/selection/text-input) is open (those take the region).
877
+ if (currentTodos.length > 0 && !dialog && !selectionDialog && !textInputDialog) {
878
+ lines.push(...buildTodoPanel());
879
+ }
654
880
  if (dialog) {
655
881
  lines.push(...buildDialog());
656
882
  }
@@ -690,6 +916,38 @@ export async function runActoviqTui(options = {}) {
690
916
  function appendStatic(lines) {
691
917
  screen.appendStatic(lines);
692
918
  }
919
+ // Inline CLI spinner for long async operations (probe, reload).
920
+ // Writes directly to stdout with \r — independent of the redrawable region.
921
+ const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
922
+ let inlineSpinnerTimer = null;
923
+ function showSpinner(message) {
924
+ let i = 0;
925
+ inlineSpinnerTimer = setInterval(() => {
926
+ process.stdout.write(`\r${A.dim}${SPINNER[i++ % SPINNER.length]} ${message}${A.reset}`);
927
+ }, 80);
928
+ }
929
+ function stopSpinner() {
930
+ if (inlineSpinnerTimer) {
931
+ clearInterval(inlineSpinnerTimer);
932
+ inlineSpinnerTimer = null;
933
+ }
934
+ process.stdout.write('\r\x1b[K');
935
+ }
936
+ /** Run an async operation with an inline spinner — no flicker for sub-100ms ops. */
937
+ async function withSpinner(message, fn) {
938
+ const start = Date.now();
939
+ showSpinner(message);
940
+ try {
941
+ const result = await fn();
942
+ const elapsed = Date.now() - start;
943
+ if (elapsed < 120)
944
+ await new Promise(r => setTimeout(r, 120 - elapsed)); // min display
945
+ return result;
946
+ }
947
+ finally {
948
+ stopSpinner();
949
+ }
950
+ }
693
951
  // ── Agent run ──────────────────────────────────────────────────────
694
952
  // /model router: pick a saved router profile (or turn routing off). When
695
953
  // active, each user turn is classified and routed to a model in startRun().
@@ -740,6 +998,50 @@ export async function runActoviqTui(options = {}) {
740
998
  appendStatic([...formatInfoLine(`router active: ${found.profile.name} — turns routed by ${found.profile.routerModel.model}`), '']);
741
999
  }
742
1000
  }
1001
+ // Expand @<image-path> tokens into Anthropic image content blocks so the
1002
+ // user can attach screenshots/designs inline (gap #4, partial — clipboard
1003
+ // capture is platform-specific, so this is the @path route only). Returns a
1004
+ // string when there are no image refs (the common case) so the run path is
1005
+ // unchanged; otherwise a ContentBlockParam[] with text + base64 image blocks.
1006
+ function expandImageRefs(text) {
1007
+ const refs = text.match(/@([\w./\\-]+\.(?:png|jpe?g|gif|webp|bmp))/gi);
1008
+ if (!refs)
1009
+ return text;
1010
+ const blocks = [];
1011
+ let cursor = 0;
1012
+ const seen = new Set();
1013
+ let imagesAdded = 0;
1014
+ for (const ref of refs) {
1015
+ const raw = ref.slice(1); // strip @
1016
+ const resolved = path.resolve(workDir, raw);
1017
+ if (seen.has(resolved))
1018
+ continue;
1019
+ let data;
1020
+ try {
1021
+ data = fs.readFileSync(resolved).toString('base64');
1022
+ }
1023
+ catch {
1024
+ continue; // not readable — leave the @token in the text below
1025
+ }
1026
+ const at = text.indexOf(ref, cursor);
1027
+ if (at > cursor)
1028
+ blocks.push({ type: 'text', text: text.slice(cursor, at) });
1029
+ const ext = path.extname(raw).slice(1).toLowerCase();
1030
+ const mediaType = ext === 'jpg' ? 'image/jpeg' : `image/${ext}`;
1031
+ blocks.push({
1032
+ type: 'image',
1033
+ source: { type: 'base64', media_type: mediaType, data },
1034
+ });
1035
+ cursor = at + ref.length;
1036
+ seen.add(resolved);
1037
+ imagesAdded++;
1038
+ }
1039
+ if (cursor < text.length)
1040
+ blocks.push({ type: 'text', text: text.slice(cursor) });
1041
+ // If no image actually loaded, fall back to the original string so the run
1042
+ // path is unchanged (the @tokens stay literal for the model to ignore).
1043
+ return imagesAdded > 0 ? blocks : text;
1044
+ }
743
1045
  async function startRun(text) {
744
1046
  running = true;
745
1047
  runStartedAt = Date.now();
@@ -754,10 +1056,11 @@ export async function runActoviqTui(options = {}) {
754
1056
  }, 120);
755
1057
  appendStatic(formatUserPrompt(text));
756
1058
  renderDynamic();
757
- // /model router: classify this turn and route it to the chosen model
758
- // (possibly on a different provider). The turn then runs normally.
1059
+ // /model router: classify this turn and route it to a model (possibly on a
1060
+ // different provider). Only applies to the in-process SDK — bridge mode
1061
+ // runs on the fixed provider+model, so routing is skipped there.
759
1062
  let routed;
760
- if (activeRouter) {
1063
+ if (activeRouter && !bridgeMode) {
761
1064
  try {
762
1065
  const decision = await resolveRoutedRun(activeRouter, text, abortCtrl.signal);
763
1066
  routed = { model: decision.model, modelApi: decision.modelApi };
@@ -769,24 +1072,66 @@ export async function runActoviqTui(options = {}) {
769
1072
  }
770
1073
  }
771
1074
  try {
772
- const stream = session.stream(text, {
773
- systemPrompt,
774
- signal: abortCtrl.signal,
775
- permissionMode: currentPermissionMode(),
776
- effort: currentEffort(),
777
- approver,
778
- ...(routed ? { model: routed.model, modelApi: routed.modelApi } : {}),
779
- ...(activeTeamTool ? { tools: [...tools, activeTeamTool] } : {}),
780
- ...(canUseTool ? { canUseTool } : {}),
781
- drainQueuedInputs: () => {
782
- const drained = queuedInputs.splice(0);
783
- return drained;
784
- },
785
- });
786
- for await (const event of stream) {
1075
+ // Branch the event source. Bridge mode spawns the configured runtime CLI
1076
+ // and adapts its events into the same AgentEvent stream the rest of this
1077
+ // loop consumes — so a bridge run reuses the spinner, tool cards, Esc
1078
+ // interrupt, steering queue, and history exactly like a normal run. The
1079
+ // bridge stream has no mid-run drain, so steering typed during a bridge
1080
+ // run queues and becomes the next turn (handled by the tail below).
1081
+ let eventStream;
1082
+ let resultPromise;
1083
+ if (bridgeMode && activeBridgeModelApi) {
1084
+ // Bridge mode: run in-process through the selected config's
1085
+ // provider/apiKey/baseURL/model (no child process). Inject the
1086
+ // pre-built {model, modelApi} into session.stream — the /model
1087
+ // router's proven mechanism for cross-provider routing. Same
1088
+ // session → context intact; switching bridge↔hadamard is seamless.
1089
+ statusNote = `bridge:${activeBridgeConfig?.name ?? 'bridge'}`;
1090
+ const stream = session.stream(expandImageRefs(text), {
1091
+ systemPrompt: applyOutputStyle(systemPrompt + buildAgentContext(), outputStyle),
1092
+ signal: abortCtrl.signal,
1093
+ permissionMode: currentPermissionMode(),
1094
+ effort: currentEffort(),
1095
+ approver,
1096
+ classifier: preToolUseHookClassifier,
1097
+ model: activeBridgeModelApi.model,
1098
+ modelApi: activeBridgeModelApi.modelApi,
1099
+ ...(activeTeamTool ? { tools: [...tools, activeTeamTool] } : {}),
1100
+ ...(canUseTool ? { canUseTool } : {}),
1101
+ drainQueuedInputs: () => {
1102
+ const drained = queuedInputs.splice(0);
1103
+ return drained;
1104
+ },
1105
+ });
1106
+ eventStream = stream;
1107
+ resultPromise = stream.result;
1108
+ }
1109
+ else {
1110
+ const stream = session.stream(expandImageRefs(text), {
1111
+ systemPrompt: applyOutputStyle(systemPrompt + buildAgentContext(), outputStyle),
1112
+ signal: abortCtrl.signal,
1113
+ permissionMode: currentPermissionMode(),
1114
+ effort: currentEffort(),
1115
+ approver,
1116
+ classifier: preToolUseHookClassifier,
1117
+ ...(routed ? { model: routed.model, modelApi: routed.modelApi } : {}),
1118
+ ...(activeTeamTool ? { tools: [...tools, activeTeamTool] } : {}),
1119
+ ...(canUseTool ? { canUseTool } : {}),
1120
+ drainQueuedInputs: () => {
1121
+ const drained = queuedInputs.splice(0);
1122
+ return drained;
1123
+ },
1124
+ });
1125
+ eventStream = stream;
1126
+ resultPromise = stream.result;
1127
+ }
1128
+ for await (const event of eventStream) {
787
1129
  handleAgentEvent(event);
788
1130
  }
789
- const result = await stream.result;
1131
+ const result = await resultPromise;
1132
+ // Accumulate token + USD usage for /cost and /usage. The model is the
1133
+ // routed model (if a router is active) or the session model. Bridge runs
1134
+ recordUsage(routed?.model ?? activeBridgeModelApi?.model ?? session.model, result.usage);
790
1135
  const rest = flusher.drain();
791
1136
  if (rest.length > 0)
792
1137
  appendStatic(rest);
@@ -864,7 +1209,26 @@ export async function runActoviqTui(options = {}) {
864
1209
  appendStatic(pending);
865
1210
  runToolCount += 1;
866
1211
  statusNote = event.call.publicName;
867
- appendStatic(formatToolCall(event.call.publicName, event.call.input, screen.width));
1212
+ toolCallNames.set(event.call.id, event.call.publicName);
1213
+ // Render Edit calls as a colored old→new diff instead of a one-liner.
1214
+ appendStatic(event.call.publicName === 'Edit'
1215
+ ? formatEditCall(event.call.input, screen.width)
1216
+ : formatToolCall(event.call.publicName, event.call.input, screen.width));
1217
+ // Capture the live todo list from TodoWrite calls so the persistent
1218
+ // panel (renderDynamic) reflects the agent's current plan + progress.
1219
+ if (event.call.publicName === 'TodoWrite') {
1220
+ const todos = event.call.input?.todos;
1221
+ if (Array.isArray(todos)) {
1222
+ currentTodos = todos
1223
+ .filter((t) => typeof t === 'object' && t !== null)
1224
+ .map(t => ({
1225
+ content: String(t.content ?? ''),
1226
+ status: String(t.status ?? 'pending'),
1227
+ activeForm: typeof t.activeForm === 'string' && t.activeForm ? t.activeForm : undefined,
1228
+ }));
1229
+ scheduleDynamicRender();
1230
+ }
1231
+ }
868
1232
  renderDynamic();
869
1233
  return;
870
1234
  }
@@ -883,6 +1247,11 @@ export async function runActoviqTui(options = {}) {
883
1247
  durationMs: event.result.durationMs,
884
1248
  outputText: event.result.outputText,
885
1249
  }, screen.width));
1250
+ // Run matching PostToolUse hooks (fire-and-forget, never block).
1251
+ const toolName = toolCallNames.get(event.result.id);
1252
+ if (toolName) {
1253
+ runPostToolUseHooks(() => readPostToolUseHooks(getLoadedJsonConfig()?.raw), toolName, null, event.result.outputText ?? '', sdk.config.workDir);
1254
+ }
886
1255
  renderDynamic();
887
1256
  return;
888
1257
  case 'conversation.compacted':
@@ -908,17 +1277,31 @@ export async function runActoviqTui(options = {}) {
908
1277
  const usage = {
909
1278
  help: '/help',
910
1279
  clear: '/clear',
1280
+ init: '/init',
911
1281
  compact: '/compact [summary instructions]',
912
1282
  memory: '/memory',
1283
+ context: '/context',
1284
+ cost: '/cost',
1285
+ usage: '/usage',
1286
+ doctor: '/doctor',
1287
+ batch: '/batch <file>',
1288
+ goal: '/goal [objective|clear|pause|resume]',
1289
+ review: '/review',
1290
+ stats: '/stats',
1291
+ export: '/export [filename]',
913
1292
  model: '/model [model|min|medium|max|default|config|router]',
914
1293
  effort: '/effort [low|medium|high|max|auto]',
1294
+ 'output-style': '/output-style [default|concise|explanatory|learning]',
915
1295
  permissions: '/permissions [default|acceptEdits|plan|bypassPermissions|auto]',
1296
+ plan: '/plan [off|open|view]',
1297
+ rewind: '/rewind <N>',
916
1298
  sessions: '/sessions',
917
1299
  resume: '/resume [session-id]',
918
1300
  tools: '/tools',
919
1301
  skills: '/skills',
920
1302
  agents: '/agents',
921
1303
  mcp: '/mcp',
1304
+ hooks: '/hooks',
922
1305
  plugins: '/plugins',
923
1306
  dream: '/dream [run|status]',
924
1307
  workflows: '/workflows [run <name>|list]',
@@ -1072,7 +1455,7 @@ export async function runActoviqTui(options = {}) {
1072
1455
  }
1073
1456
  await persistActoviqSettingsStore(store.configPath, raw);
1074
1457
  await loadJsonConfigFile(store.configPath);
1075
- await reloadCleanSdk();
1458
+ await withSpinner('reloading SDK', reloadCleanSdk);
1076
1459
  appendStatic([
1077
1460
  ...formatInfoLine(`model settings saved: ${store.configPath}`),
1078
1461
  '',
@@ -1142,6 +1525,498 @@ export async function runActoviqTui(options = {}) {
1142
1525
  }
1143
1526
  }
1144
1527
  }
1528
+ async function configureBridgeSettings() {
1529
+ const store = await resolveActoviqSettingsStore({ configPath: options.configPath });
1530
+ const raw = structuredClone(store.raw);
1531
+ const bridge = raw.bridge ?? {};
1532
+ let dirty = false;
1533
+ const detections = await detectBridgeProviders();
1534
+ while (true) {
1535
+ const defaultLabel = (typeof bridge.defaultProvider === 'string' ? bridge.defaultProvider : 'claude');
1536
+ const providerItems = detections.map((d) => ({
1537
+ id: `provider:${d.id}`,
1538
+ label: `${d.id}${d.available ? '' : ' (not found)'}`,
1539
+ description: d.version ? `v${d.version}${d.path ? ` · ${d.path}` : ''}` : 'not detected',
1540
+ }));
1541
+ const selected = await selectItem({
1542
+ title: 'Bridge runtime settings',
1543
+ subtitle: store.configPath,
1544
+ searchable: false,
1545
+ items: [
1546
+ { id: 'default', label: 'Default provider', description: defaultLabel },
1547
+ ...providerItems,
1548
+ { id: 'save', label: 'Save and apply', description: dirty ? 'Unsaved changes' : 'No changes' },
1549
+ { id: 'cancel', label: 'Cancel' },
1550
+ ],
1551
+ });
1552
+ if (!selected || selected === 'cancel')
1553
+ return;
1554
+ if (selected === 'save') {
1555
+ if (!dirty) {
1556
+ appendStatic([...formatInfoLine('bridge settings unchanged'), '']);
1557
+ return;
1558
+ }
1559
+ raw.bridge = bridge;
1560
+ await persistActoviqSettingsStore(store.configPath, raw);
1561
+ await loadJsonConfigFile(store.configPath);
1562
+ appendStatic([...formatInfoLine(`bridge settings saved: ${store.configPath}`), '']);
1563
+ return;
1564
+ }
1565
+ if (selected === 'default') {
1566
+ const providerChoices = detections.map((d) => ({
1567
+ id: d.id,
1568
+ label: d.id,
1569
+ description: d.available ? (d.version ?? 'detected') : 'not found',
1570
+ }));
1571
+ const provider = await selectItem({
1572
+ title: 'Select default bridge provider',
1573
+ searchable: false,
1574
+ items: providerChoices,
1575
+ });
1576
+ if (provider) {
1577
+ bridge.defaultProvider = provider;
1578
+ dirty = true;
1579
+ }
1580
+ continue;
1581
+ }
1582
+ if (selected.startsWith('provider:')) {
1583
+ const pid = selected.slice('provider:'.length);
1584
+ const providers = bridge.providers ?? {};
1585
+ const entry = providers[pid] ?? {};
1586
+ const pathInput = await promptText({
1587
+ title: `Executable path for ${pid}`,
1588
+ label: 'Path',
1589
+ description: `Leave empty to auto-detect on PATH. Current: ${entry.path ?? 'auto'}`,
1590
+ initial: entry.path ?? '',
1591
+ });
1592
+ if (pathInput !== undefined) {
1593
+ if (pathInput.trim())
1594
+ entry.path = pathInput.trim();
1595
+ else
1596
+ delete entry.path;
1597
+ providers[pid] = entry;
1598
+ bridge.providers = providers;
1599
+ dirty = true;
1600
+ }
1601
+ continue;
1602
+ }
1603
+ }
1604
+ }
1605
+ async function runBridgePrompt(prompt) {
1606
+ // /bridge run forces a bridge turn. If no config is active, open the board
1607
+ // (or error if none saved) rather than auto-picking a detected runtime.
1608
+ if (!bridgeMode || !activeBridgeModelApi) {
1609
+ const configs = readBridgeConfigs().configs;
1610
+ if (configs.length === 0) {
1611
+ appendStatic([...formatErrorLine('No bridge configs saved. Use /bridge config to add one.'), '']);
1612
+ return;
1613
+ }
1614
+ appendStatic([...formatInfoLine('No active bridge config — select one from the board.'), '']);
1615
+ return;
1616
+ }
1617
+ await startRun(prompt);
1618
+ }
1619
+ async function disableBridge() {
1620
+ // Switch back to the SDK's default model/provider. The conversation
1621
+ // context stays intact (same session). Config stays saved for re-activation.
1622
+ bridgeMode = false;
1623
+ activeBridgeConfig = null;
1624
+ activeBridgeModelApi = null;
1625
+ appendStatic([
1626
+ ...formatInfoLine('bridge mode off — back to default provider (session intact)'),
1627
+ '',
1628
+ ]);
1629
+ }
1630
+ function printBridgeHelp() {
1631
+ appendStatic([
1632
+ ...formatInfoLine('/bridge sub-commands:'),
1633
+ ` ${A.dim}(bare)${A.reset} — list saved configs; pick one to activate`,
1634
+ ` ${A.dim}config${A.reset} — add / edit / remove named connection configs`,
1635
+ ` ${A.dim}run <prompt>${A.reset} — run one turn through the active runtime`,
1636
+ ` ${A.dim}switch <name>${A.reset} — activate a saved config by name (or a raw provider id)`,
1637
+ ` ${A.dim}model [id]${A.reset} — set model for the current runtime`,
1638
+ ` ${A.dim}setup${A.reset} — detect + configure paths (legacy)`,
1639
+ ` ${A.dim}off${A.reset} — disable bridge mode`,
1640
+ ` ${A.dim}help${A.reset} — show this list`,
1641
+ ...formatInfoLine('a saved config bundles name + provider + apiKey + baseURL + model;'),
1642
+ ...formatInfoLine('activating it runs every prompt through that runtime with those'),
1643
+ ...formatInfoLine('credentials injected (multi-turn via --resume). configs persist in'),
1644
+ ...formatInfoLine('~/.actoviq/bridge-configs.json. provider multi-turn sessions are'),
1645
+ ...formatInfoLine('retained across /bridge off; bridge turns also save to the hadamard session.'),
1646
+ '',
1647
+ ]);
1648
+ }
1649
+ async function openBridgeBoard() {
1650
+ // The /bridge board lists SAVED connection configs (the user's presets).
1651
+ // Selecting one activates that runtime with the config's credentials
1652
+ // injected. No-config fallbacks (legacy provider switch, setup, detect)
1653
+ // live under the actions so nothing is lost.
1654
+ const configs = readBridgeConfigs().configs;
1655
+ const state = bridgeMode
1656
+ ? `${A.green}(active)${A.reset}`
1657
+ : `${A.dim}(idle)${A.reset}`;
1658
+ const activeCfg = activeBridgeConfig?.name ?? (bridgeMode ? 'active' : null);
1659
+ const lines = [
1660
+ `Bridge ${state}${activeCfg ? ` · ${A.bold}${activeCfg}${A.reset}` : ''}`,
1661
+ ...formatDivider(screen.width),
1662
+ ];
1663
+ if (configs.length === 0) {
1664
+ lines.push(` ${A.dim}no saved configs — use "/bridge config" to add one${A.reset}`);
1665
+ }
1666
+ else {
1667
+ for (const c of configs) {
1668
+ const active = activeBridgeConfig?.name === c.name;
1669
+ const mark = active ? `${A.green}●${A.reset}` : `${A.dim}○${A.reset}`;
1670
+ lines.push(` ${mark} ${A.bold}${c.name}${A.reset} ${A.dim}· ${c.provider}${A.reset}${c.model ? ` ${A.dim}· ${c.model}${A.reset}` : ''}`);
1671
+ lines.push(` ${A.dim}key: ${maskApiKey(c.apiKey)}${c.baseURL ? ` · ${c.baseURL}` : ''}${A.reset}`);
1672
+ }
1673
+ }
1674
+ lines.push('');
1675
+ appendStatic(lines);
1676
+ const choice = await selectItem({
1677
+ title: 'Bridge',
1678
+ subtitle: activeCfg ? `active: ${activeCfg}` : 'no active config',
1679
+ searchable: false,
1680
+ items: [
1681
+ // One item per saved config → selecting activates it.
1682
+ ...configs.map(c => ({
1683
+ id: `c:${c.name}`,
1684
+ label: `${c.name}${activeBridgeConfig?.name === c.name ? ' *' : ''}`,
1685
+ description: `${c.provider} · ${maskApiKey(c.apiKey)}${c.model ? ` · ${c.model}` : ''}`,
1686
+ })),
1687
+ { id: 'config', label: '⚙ Manage configs…', description: 'add / edit / remove saved configs' },
1688
+ { id: 'run', label: '▶ Run a prompt…', description: 'run one turn through the active runtime' },
1689
+ { id: 'model', label: '◈ Model', description: 'set model for the active runtime' },
1690
+ { id: 'setup', label: '✎ Edit paths…', description: 'per-provider executable + default (legacy)' },
1691
+ { id: 'detect', label: '↻ Re-detect runtimes', description: 're-scan PATH for installed CLIs' },
1692
+ ...(bridgeMode ? [{ id: 'off', label: '■ Disable bridge', description: 'back to in-process SDK' }] : []),
1693
+ { id: 'help', label: '? Help', description: 'show /bridge sub-commands' },
1694
+ ],
1695
+ });
1696
+ if (!choice)
1697
+ return;
1698
+ if (choice.startsWith('c:')) {
1699
+ const name = choice.slice(2);
1700
+ const cfg = configs.find(c => c.name === name);
1701
+ if (cfg)
1702
+ await activateBridgeConfig(cfg);
1703
+ return;
1704
+ }
1705
+ if (choice === 'config') {
1706
+ await manageBridgeConfigs();
1707
+ return;
1708
+ }
1709
+ if (choice === 'run') {
1710
+ const task = await promptText({ title: 'Bridge run', label: 'Prompt' });
1711
+ if (task?.trim())
1712
+ await runBridgePrompt(task.trim());
1713
+ return;
1714
+ }
1715
+ if (choice === 'model') {
1716
+ await selectBridgeModel();
1717
+ return;
1718
+ }
1719
+ if (choice === 'setup') {
1720
+ await configureBridgeSettings();
1721
+ return;
1722
+ }
1723
+ if (choice === 'detect') {
1724
+ const refreshed = await detectBridgeProviders();
1725
+ appendStatic(['', ...refreshed.map((d) => `${d.available ? '✔' : '✘'} ${d.id} ${d.version ?? ''}`), '']);
1726
+ return;
1727
+ }
1728
+ if (choice === 'off') {
1729
+ await disableBridge();
1730
+ return;
1731
+ }
1732
+ if (choice === 'help') {
1733
+ printBridgeHelp();
1734
+ return;
1735
+ }
1736
+ }
1737
+ // Activate a named bridge config — the in-process path. Pre-builds a ModelApi
1738
+ // via buildRouteModelApi so each turn can inject {model, modelApi} into
1739
+ // session.stream (same session, context naturally survives switching).
1740
+ async function activateBridgeConfig(config) {
1741
+ try {
1742
+ const routed = await buildRouteModelApi({
1743
+ model: config.model || session.model,
1744
+ provider: config.provider,
1745
+ baseURL: config.baseURL,
1746
+ apiKey: config.apiKey,
1747
+ maxTokens: 32000,
1748
+ });
1749
+ activeBridgeModelApi = routed;
1750
+ bridgeModelLabel = routed.model;
1751
+ activeBridgeConfig = config;
1752
+ bridgeMode = true;
1753
+ appendStatic([
1754
+ ...formatInfoLine(`bridge active — config: ${config.name} · provider: ${config.provider} · model: ${routed.model}`),
1755
+ ...formatInfoLine(`apiKey: ${maskApiKey(config.apiKey)}${config.baseURL ? ` · baseURL: ${config.baseURL}` : ''}`),
1756
+ ...formatInfoLine(`normal prompts now run through ${config.name}; /bridge off switches back to the default provider`),
1757
+ '',
1758
+ ]);
1759
+ return true;
1760
+ }
1761
+ catch (error) {
1762
+ appendStatic([...formatErrorLine(`bridge activation failed: ${error.message}`), '']);
1763
+ return false;
1764
+ }
1765
+ }
1766
+ // /bridge config — manage named connection configs (the management screen).
1767
+ async function manageBridgeConfigs() {
1768
+ while (true) {
1769
+ const store = readBridgeConfigs();
1770
+ const lines = [
1771
+ `${A.bold}Bridge configs${A.reset} ${A.dim}(~/.actoviq/bridge-configs.json)${A.reset}`,
1772
+ ];
1773
+ if (store.configs.length === 0) {
1774
+ lines.push(` ${A.dim}no configs yet — add one to connect a runtime by name${A.reset}`);
1775
+ }
1776
+ else {
1777
+ for (const c of store.configs) {
1778
+ const active = activeBridgeConfig?.name === c.name;
1779
+ const star = active ? ` ${A.green}*${A.reset}` : '';
1780
+ lines.push(` ${A.bold}${c.name}${A.reset} ${A.dim}· ${c.provider}${A.reset}${star}`);
1781
+ lines.push(` ${A.dim}key: ${maskApiKey(c.apiKey)}${c.baseURL ? ` · ${c.baseURL}` : ''}${c.model ? ` · ${c.model}` : ''}${A.reset}`);
1782
+ }
1783
+ }
1784
+ lines.push('');
1785
+ appendStatic(lines);
1786
+ const choice = await selectItem({
1787
+ title: 'Bridge configs',
1788
+ searchable: false,
1789
+ items: [
1790
+ { id: 'add', label: '+ Add config…', description: 'open the config editor with empty fields' },
1791
+ ...(store.configs.length > 0
1792
+ ? [
1793
+ { id: 'edit', label: '✎ Edit config…', description: 'modify any field of a saved config' },
1794
+ { id: 'remove', label: '− Remove config…', description: 'delete a saved config' },
1795
+ ]
1796
+ : []),
1797
+ { id: 'back', label: '↩ Back', description: 'return to the prompt' },
1798
+ ],
1799
+ });
1800
+ if (!choice || choice === 'back')
1801
+ return;
1802
+ if (choice === 'add') {
1803
+ const created = await editBridgeConfig();
1804
+ if (created) {
1805
+ addBridgeConfig(created);
1806
+ appendStatic([...formatInfoLine(`saved config "${created.name}" — select it via /bridge to activate`), '']);
1807
+ }
1808
+ continue;
1809
+ }
1810
+ if (choice === 'edit') {
1811
+ const name = await selectItem({
1812
+ title: 'Edit config',
1813
+ searchable: false,
1814
+ items: store.configs.map(c => ({ id: c.name, label: c.name, description: `${c.provider} · ${maskApiKey(c.apiKey)}` })),
1815
+ });
1816
+ if (!name)
1817
+ continue;
1818
+ const existing = store.configs.find(c => c.name === name);
1819
+ const updated = await editBridgeConfig(existing);
1820
+ if (updated) {
1821
+ addBridgeConfig(updated); // dedupe-by-name replaces
1822
+ // If the edited config is active, refresh the live activeBridgeConfig.
1823
+ if (activeBridgeConfig?.name === existing.name || activeBridgeConfig?.name === updated.name) {
1824
+ activeBridgeConfig = updated;
1825
+ appendStatic([...formatInfoLine(`active config "${updated.name}" updated — applies next turn`), '']);
1826
+ }
1827
+ else {
1828
+ appendStatic([...formatInfoLine(`config "${updated.name}" saved`), '']);
1829
+ }
1830
+ }
1831
+ continue;
1832
+ }
1833
+ if (choice === 'remove') {
1834
+ const name = await selectItem({
1835
+ title: 'Remove config',
1836
+ searchable: false,
1837
+ items: store.configs.map(c => ({ id: c.name, label: c.name, description: `${c.provider} · ${maskApiKey(c.apiKey)}` })),
1838
+ });
1839
+ if (!name)
1840
+ continue;
1841
+ removeBridgeConfig(name);
1842
+ if (activeBridgeConfig?.name === name) {
1843
+ appendStatic([...formatInfoLine(`removed active config "${name}" — bridge mode still on (provider: ${activeBridgeConfig?.provider ?? '?'})`), '']);
1844
+ }
1845
+ else {
1846
+ appendStatic([...formatInfoLine(`removed config "${name}"`), '']);
1847
+ }
1848
+ continue;
1849
+ }
1850
+ }
1851
+ }
1852
+ // Single-page config editor: shows ALL fields at once (with current values),
1853
+ // and the user can edit any field in any order — e.g. set the key, then go
1854
+ // back and change the name — before Save / Cancel. A field is selected via the
1855
+ // menu, re-prompted, then the loop re-renders the whole form with the new
1856
+ // value. Returns the config on Save, undefined on Cancel.
1857
+ async function editBridgeConfig(existing) {
1858
+ // Work on a local copy so Cancel discards all edits.
1859
+ const draft = existing
1860
+ ? { name: existing.name, provider: existing.provider, runtime: existing.runtime, ...(existing.apiKey ? { apiKey: existing.apiKey } : {}), ...(existing.baseURL ? { baseURL: existing.baseURL } : {}), ...(existing.model ? { model: existing.model } : {}) }
1861
+ : { name: '', provider: 'anthropic', runtime: 'claude' };
1862
+ // Lazy-detect: runtimes are probed only when the user opens the runtime
1863
+ // picker, so the form renders instantly. Cached after first probe.
1864
+ let detections = null;
1865
+ while (true) {
1866
+ // Render the live form.
1867
+ const header = existing ? `Editing "${existing.name}"` : 'New bridge config';
1868
+ const lines = [
1869
+ `${A.bold}${header}${A.reset} — edit any field, then Save`,
1870
+ ...formatDivider(screen.width),
1871
+ ` ${A.bold}name${A.reset} ${draft.name || `${A.dim}(unset)${A.reset}`}`,
1872
+ ` ${A.bold}runtime${A.reset} ${A.bold}${draft.provider === 'anthropic' ? 'claude / codewhale / reasonix' : 'pi / codex / crush'}${A.reset} ${A.dim}(${draft.provider})${A.reset}`,
1873
+ ` ${A.bold}apiKey${A.reset} ${maskApiKey(draft.apiKey)}`,
1874
+ ` ${A.bold}baseURL${A.reset} ${draft.baseURL || `${A.dim}(inherit)${A.reset}`}`,
1875
+ ` ${A.bold}model${A.reset} ${draft.model || `${A.dim}(inherit)${A.reset}`}`,
1876
+ '',
1877
+ ];
1878
+ appendStatic(lines);
1879
+ const choice = await selectItem({
1880
+ title: existing ? `Edit ${existing.name}` : 'New config',
1881
+ subtitle: 'edit any field in any order · Save to commit',
1882
+ searchable: false,
1883
+ items: [
1884
+ { id: 'name', label: `name: ${draft.name || '(unset)'}`, description: 'a label you pick, e.g. deepseek-claude' },
1885
+ { id: 'provider', label: `runtime: ${draft.provider === 'anthropic' ? 'claude / codewhale / reasonix' : 'pi / codex / crush'}`, description: `${draft.provider} · pick from detected runtimes` },
1886
+ { id: 'apiKey', label: `apiKey: ${maskApiKey(draft.apiKey)}`, description: 'injected as the credential each turn (hidden input)' },
1887
+ { id: 'baseURL', label: `baseURL: ${draft.baseURL || '(inherit)'}`, description: 'the backend endpoint (e.g. https://api.deepseek.com)' },
1888
+ { id: 'model', label: `model: ${draft.model || '(inherit)'}`, description: 'optional model id' },
1889
+ { id: 'save', label: '💾 Save config', description: draft.name ? `commit "${draft.name}"` : 'a name is required to save' },
1890
+ { id: 'cancel', label: '✕ Cancel', description: 'discard changes' },
1891
+ ],
1892
+ });
1893
+ if (!choice || choice === 'cancel')
1894
+ return undefined;
1895
+ if (choice === 'save') {
1896
+ const name = draft.name.trim();
1897
+ if (!name) {
1898
+ appendStatic([...formatErrorLine('cannot save — name is required (edit the name field first)'), '']);
1899
+ continue;
1900
+ }
1901
+ const config = { name, provider: draft.provider, runtime: draft.runtime };
1902
+ if (draft.apiKey)
1903
+ config.apiKey = draft.apiKey;
1904
+ if (draft.baseURL)
1905
+ config.baseURL = draft.baseURL;
1906
+ if (draft.model)
1907
+ config.model = draft.model;
1908
+ if (draft.apiKey)
1909
+ config.apiKey = draft.apiKey;
1910
+ if (draft.baseURL)
1911
+ config.baseURL = draft.baseURL;
1912
+ if (draft.model)
1913
+ config.model = draft.model;
1914
+ return config;
1915
+ }
1916
+ if (choice === 'name') {
1917
+ const v = (await promptText({ title: 'Config name', label: 'name', initial: draft.name, description: 'a label you pick, e.g. deepseek-claude' }))?.trim();
1918
+ if (v !== undefined)
1919
+ draft.name = v;
1920
+ continue;
1921
+ }
1922
+ if (choice === 'provider') {
1923
+ // Show detected runtimes as the primary options; each maps to an
1924
+ // in-process provider. Detection is lazy — probed only when the user
1925
+ // opens this picker, so the form renders instantly.
1926
+ if (!detections) {
1927
+ detections = await withSpinner('scanning for runtimes', detectBridgeProviders);
1928
+ }
1929
+ const RUNTIME_MAP = {
1930
+ claude: { provider: 'anthropic', label: 'claude' },
1931
+ codewhale: { provider: 'anthropic', label: 'codewhale' },
1932
+ reasonix: { provider: 'anthropic', label: 'reasonix' },
1933
+ pi: { provider: 'openai', label: 'pi' },
1934
+ codex: { provider: 'openai', label: 'codex' },
1935
+ crush: { provider: 'openai', label: 'crush' },
1936
+ };
1937
+ const curProvider = draft.provider;
1938
+ const curRuntime = Object.entries(RUNTIME_MAP).find(([, v]) => v.provider === curProvider)?.[0];
1939
+ const items = detections.map(d => ({
1940
+ id: d.id,
1941
+ label: `${d.id}${d.id === curRuntime ? ' ✓' : ''}`,
1942
+ description: `${RUNTIME_MAP[d.id]?.provider ?? '?'}${d.available ? (d.version ? ` · v${d.version}` : ' · detected') : ' · not found'}${d.id === 'reasonix' ? ' · DeepSeek' : d.id === 'crush' ? ' · multi-backend' : ''}`,
1943
+ }));
1944
+ const v = await selectItem({
1945
+ title: 'Runtime',
1946
+ subtitle: curRuntime ? `current: ${curRuntime} (${curProvider})` : `current provider: ${curProvider}`,
1947
+ searchable: false,
1948
+ items,
1949
+ });
1950
+ if (v) {
1951
+ const mapped = RUNTIME_MAP[v] ?? { provider: 'anthropic' };
1952
+ draft.provider = mapped.provider;
1953
+ }
1954
+ continue;
1955
+ }
1956
+ if (choice === 'apiKey') {
1957
+ const initial = draft.apiKey;
1958
+ const v = await promptText({
1959
+ title: 'API key',
1960
+ label: 'api key',
1961
+ secret: true,
1962
+ initial,
1963
+ description: 'hidden input · clear to inherit from settings',
1964
+ });
1965
+ if (v !== undefined)
1966
+ draft.apiKey = v.trim() || undefined;
1967
+ continue;
1968
+ }
1969
+ if (choice === 'baseURL') {
1970
+ const v = (await promptText({ title: 'Base URL', label: 'base url', initial: draft.baseURL, description: 'the backend endpoint; leave empty to inherit' }))?.trim();
1971
+ if (v !== undefined)
1972
+ draft.baseURL = v || undefined;
1973
+ continue;
1974
+ }
1975
+ if (choice === 'model') {
1976
+ const v = (await promptText({
1977
+ title: 'Model',
1978
+ label: 'model',
1979
+ initial: draft.model,
1980
+ description: 'a model id (optional, e.g. deepseek-chat, claude-sonnet-4-6)',
1981
+ }))?.trim();
1982
+ if (v !== undefined)
1983
+ draft.model = v || undefined;
1984
+ continue;
1985
+ }
1986
+ }
1987
+ }
1988
+ async function switchBridgeProvider(target) {
1989
+ if (!target) {
1990
+ appendStatic([...formatInfoLine('usage: /bridge switch <config-name> (or open /bridge to pick)'), '']);
1991
+ return;
1992
+ }
1993
+ const cfg = findBridgeConfig(target);
1994
+ if (cfg) {
1995
+ await activateBridgeConfig(cfg);
1996
+ return;
1997
+ }
1998
+ appendStatic([...formatErrorLine(`unknown config: ${target} — use /bridge config to add one, or open /bridge to pick`), '']);
1999
+ }
2000
+ async function selectBridgeModel(modelId = '') {
2001
+ const cfgName = activeBridgeConfig?.name ?? 'active';
2002
+ if (modelId) {
2003
+ // Direct set: /bridge model claude-sonnet-4-6
2004
+ bridgeModelLabel = modelId;
2005
+ appendStatic([...formatInfoLine(`bridge model → ${modelId}`), '']);
2006
+ return;
2007
+ }
2008
+ // Picker: prompt for a model ID.
2009
+ const v = (await promptText({
2010
+ title: `Bridge model for ${cfgName}`,
2011
+ label: 'Model ID',
2012
+ initial: bridgeModelLabel ?? '',
2013
+ description: 'enter the model id to use with the bridge config',
2014
+ }));
2015
+ if (v !== undefined) {
2016
+ bridgeModelLabel = v.trim() || null;
2017
+ appendStatic([...formatInfoLine(`bridge model → ${bridgeModelLabel || 'session default'}`), '']);
2018
+ }
2019
+ }
1145
2020
  async function chooseEffort() {
1146
2021
  const selected = await selectItem({
1147
2022
  title: 'Select reasoning effort',
@@ -1168,6 +2043,58 @@ export async function runActoviqTui(options = {}) {
1168
2043
  });
1169
2044
  appendStatic([...formatInfoLine(`effort set to: ${currentEffort() ?? 'auto'}`), '']);
1170
2045
  }
2046
+ // ── /goal: session-scoped goal with status tracking ─────────────────
2047
+ const GOAL_METADATA_KEY = '__actoviqGoal';
2048
+ function getGoal() {
2049
+ const raw = session.metadata[GOAL_METADATA_KEY];
2050
+ if (typeof raw === 'string') {
2051
+ try {
2052
+ return JSON.parse(raw);
2053
+ }
2054
+ catch { /* ignore */ }
2055
+ }
2056
+ if (typeof raw === 'object' && raw !== null)
2057
+ return raw;
2058
+ return null;
2059
+ }
2060
+ async function setGoal(objective) {
2061
+ const goal = { objective, status: 'active', setAt: new Date().toISOString() };
2062
+ await session.mergeMetadata({ [GOAL_METADATA_KEY]: goal });
2063
+ }
2064
+ async function clearGoal() {
2065
+ const md = { ...session.metadata };
2066
+ delete md[GOAL_METADATA_KEY];
2067
+ await session.mergeMetadata(md);
2068
+ }
2069
+ function goalContextLine() {
2070
+ const g = getGoal();
2071
+ if (!g)
2072
+ return '';
2073
+ const statusMarks = { active: `${A.green}▶${A.reset}`, paused: `${A.yellow}‖${A.reset}`, complete: `${A.dim}✓${A.reset}` };
2074
+ return ` · goal:${statusMarks[g.status] ?? ''}${A.dim}${truncateToWidth(g.objective, 30)}${A.reset}`;
2075
+ }
2076
+ async function chooseOutputStyle(arg) {
2077
+ const valid = OUTPUT_STYLES.map(s => s.id);
2078
+ if (arg) {
2079
+ if (!valid.includes(arg)) {
2080
+ appendStatic([...formatErrorLine(`unknown output style: ${arg}. Valid: ${valid.join(', ')}`), '']);
2081
+ return;
2082
+ }
2083
+ outputStyle = arg;
2084
+ appendStatic([...formatInfoLine(`output style → ${outputStyle}`), '']);
2085
+ return;
2086
+ }
2087
+ const selected = await selectItem({
2088
+ title: 'Select output style',
2089
+ subtitle: `Current: ${outputStyle}`,
2090
+ searchable: false,
2091
+ items: OUTPUT_STYLES.map(s => ({ id: s.id, label: s.label, description: s.description })),
2092
+ });
2093
+ if (selected) {
2094
+ outputStyle = selected;
2095
+ appendStatic([...formatInfoLine(`output style → ${outputStyle}`), '']);
2096
+ }
2097
+ }
1171
2098
  async function showSkills() {
1172
2099
  const skills = sdk.skills.listMetadata();
1173
2100
  if (skills.length === 0) {
@@ -1226,23 +2153,100 @@ export async function runActoviqTui(options = {}) {
1226
2153
  tools.push(tool);
1227
2154
  byServer.set(server, tools);
1228
2155
  }
1229
- if (byServer.size === 0) {
1230
- appendStatic([...formatInfoLine('no MCP servers are active'), '']);
1231
- return;
2156
+ const persisted = readMcpServerConfig();
2157
+ const lines = [];
2158
+ if (byServer.size > 0) {
2159
+ lines.push(`${A.bold}Active MCP servers${A.reset} ${A.dim}(${byServer.size})${A.reset}`);
2160
+ for (const [server, tools] of byServer) {
2161
+ lines.push(` ${A.green}●${A.reset} ${A.bold}${server}${A.reset} ${A.dim}— ${tools.length} tool${tools.length === 1 ? '' : 's'}${A.reset}`);
2162
+ }
1232
2163
  }
1233
- const selected = await selectItem({
2164
+ else {
2165
+ lines.push(`${A.dim}no MCP servers are active${A.reset}`);
2166
+ }
2167
+ if (persisted.servers.length > 0) {
2168
+ lines.push(`${A.bold}Configured servers${A.reset} ${A.dim}(~/.actoviq/mcp.json)${A.reset}`);
2169
+ for (const s of persisted.servers) {
2170
+ lines.push(` ${A.dim}·${A.reset} ${s.name} ${A.dim}→ ${s.command}${s.args?.length ? ' ' + s.args.join(' ') : ''}${A.reset}`);
2171
+ }
2172
+ }
2173
+ lines.push('');
2174
+ appendStatic(lines);
2175
+ const choice = await selectItem({
1234
2176
  title: 'MCP servers',
1235
- items: [...byServer.entries()].map(([server, tools]) => ({
1236
- id: server,
1237
- label: server,
1238
- description: `${tools.length} tool${tools.length === 1 ? '' : 's'}`,
1239
- detail: tools.map(tool => tool.name).join(', '),
1240
- })),
2177
+ searchable: false,
2178
+ items: [
2179
+ { id: 'add', label: '+ Add stdio server…', description: 'persist a stdio MCP server to ~/.actoviq/mcp.json' },
2180
+ ...(persisted.servers.length > 0
2181
+ ? [{ id: 'remove', label: '− Remove server…', description: 'delete a configured server and reload' }]
2182
+ : []),
2183
+ { id: 'reload', label: '↻ Reload SDK', description: 'recreate the client to pick up config changes' },
2184
+ ...(byServer.size > 0
2185
+ ? [...byServer.entries()].map(([server, tools]) => ({
2186
+ id: `view:${server}`,
2187
+ label: server,
2188
+ description: `${tools.length} tool${tools.length === 1 ? '' : 's'}: ${tools.map(t => t.name).slice(0, 6).join(', ')}${tools.length > 6 ? '…' : ''}`,
2189
+ }))
2190
+ : []),
2191
+ ],
1241
2192
  });
1242
- if (selected) {
2193
+ if (!choice)
2194
+ return;
2195
+ if (choice === 'add') {
2196
+ const name = (await promptText({ title: 'MCP server name', label: 'name' }))?.trim();
2197
+ if (!name)
2198
+ return;
2199
+ const kind = await selectItem({
2200
+ title: 'Server type',
2201
+ items: [
2202
+ { id: 'stdio', label: 'stdio', description: 'local process (e.g. npx, python, a binary)' },
2203
+ { id: 'http', label: 'http', description: 'remote streamable HTTP MCP server (url)' },
2204
+ ],
2205
+ });
2206
+ if (!kind)
2207
+ return;
2208
+ if (kind === 'stdio') {
2209
+ const command = (await promptText({ title: `Command for ${name}`, label: 'command', description: 'e.g. npx or a binary path' }))?.trim();
2210
+ if (!command)
2211
+ return;
2212
+ const argsRaw = await promptText({ title: `Args for ${name}`, label: 'args', description: 'space-separated (optional)' });
2213
+ addMcpServer({ name, command, ...(argsRaw?.trim() ? { args: argsRaw.trim().split(/\s+/) } : {}) });
2214
+ }
2215
+ else {
2216
+ const url = (await promptText({ title: `URL for ${name}`, label: 'url', description: 'e.g. https://mcp.example.com' }))?.trim();
2217
+ if (!url)
2218
+ return;
2219
+ const headersRaw = await promptText({ title: `Headers for ${name}`, label: 'headers', description: 'key:value, comma-separated (optional)' });
2220
+ const headers = headersRaw?.trim() ? Object.fromEntries(headersRaw.split(',').map(p => p.split(':').map(s => s.trim())).filter(a => a.length === 2)) : undefined;
2221
+ addMcpServer({ name, url, ...(headers ? { headers } : {}) });
2222
+ }
2223
+ appendStatic([...formatInfoLine(`added MCP server "${name}" — reloading SDK`), '']);
2224
+ await withSpinner('reloading SDK', reloadCleanSdk);
2225
+ return;
2226
+ }
2227
+ if (choice === 'remove') {
2228
+ const name = await selectItem({
2229
+ title: 'Remove MCP server',
2230
+ searchable: false,
2231
+ items: persisted.servers.map(s => ({ id: s.name, label: s.name, description: `${s.command}` })),
2232
+ });
2233
+ if (!name)
2234
+ return;
2235
+ removeMcpServer(name);
2236
+ appendStatic([...formatInfoLine(`removed MCP server "${name}" — reloading SDK`), '']);
2237
+ await withSpinner('reloading SDK', reloadCleanSdk);
2238
+ return;
2239
+ }
2240
+ if (choice === 'reload') {
2241
+ await withSpinner('reloading SDK', reloadCleanSdk);
2242
+ appendStatic([...formatInfoLine('SDK reloaded — MCP config re-read'), '']);
2243
+ return;
2244
+ }
2245
+ if (choice.startsWith('view:')) {
2246
+ const server = choice.slice('view:'.length);
1243
2247
  appendStatic([
1244
- `${A.cyan}${selected}${A.reset}`,
1245
- `${A.dim}${(byServer.get(selected) ?? []).map(tool => tool.name).join(', ')}${A.reset}`,
2248
+ `${A.cyan}${server}${A.reset}`,
2249
+ `${A.dim}${(byServer.get(server) ?? []).map(tool => tool.name).join(', ')}${A.reset}`,
1246
2250
  '',
1247
2251
  ]);
1248
2252
  }
@@ -1342,6 +2346,13 @@ export async function runActoviqTui(options = {}) {
1342
2346
  process.stdout.write('\x1b[2J\x1b[H');
1343
2347
  renderDynamic();
1344
2348
  return;
2349
+ case 'init': {
2350
+ // Bootstrap a CLAUDE.md by having the agent explore the repo and
2351
+ // write concise guidance — complements the CLAUDE.md loader (the
2352
+ // generated file is then injected into every system prompt).
2353
+ await startRun('Create or update a CLAUDE.md at the repo root with concise guidance for AI coding assistants: the build/test/lint/run commands, a short architecture overview, key conventions, and non-obvious gotchas. Explore with Glob, Grep, and Read first (package.json, README, existing CLAUDE.md, key source dirs). If a CLAUDE.md already exists, improve it without discarding user-authored sections. Keep it focused — no filler.');
2354
+ return;
2355
+ }
1345
2356
  case 'exit':
1346
2357
  case 'quit':
1347
2358
  await shutdown(0);
@@ -1369,6 +2380,9 @@ export async function runActoviqTui(options = {}) {
1369
2380
  else
1370
2381
  await setEffort(args.toLowerCase());
1371
2382
  return;
2383
+ case 'output-style':
2384
+ await chooseOutputStyle(args.toLowerCase());
2385
+ return;
1372
2386
  case 'permissions': {
1373
2387
  // Three presets, selectable or named directly:
1374
2388
  // read-only → deny mutating tools (read / search / web only)
@@ -1411,6 +2425,70 @@ export async function runActoviqTui(options = {}) {
1411
2425
  ]);
1412
2426
  return;
1413
2427
  }
2428
+ case 'plan': {
2429
+ // Plan mode (gap #6). The model can enter/exit via EnterPlanMode /
2430
+ // ExitPlanMode tools; /plan toggles the permission mode and lets the
2431
+ // user view/open the plan file the agent wrote.
2432
+ const arg = args.trim().toLowerCase();
2433
+ if (arg === 'off') {
2434
+ await session.setPermissionContext({ mode: permissionMode === 'bypassPermissions' ? 'bypassPermissions' : 'default', permissions: [], approver });
2435
+ appendStatic([...formatInfoLine('plan mode off — back to default permissions'), '']);
2436
+ return;
2437
+ }
2438
+ if (arg === 'open') {
2439
+ const file = planFilePath(sdk.config.workDir);
2440
+ try {
2441
+ const editorBin = process.env.EDITOR || process.env.VISUAL || 'notepad';
2442
+ spawnSync(editorBin, [file], { stdio: 'ignore', shell: false });
2443
+ }
2444
+ catch {
2445
+ appendStatic([...formatErrorLine(`could not open plan file: ${file}`), '']);
2446
+ }
2447
+ return;
2448
+ }
2449
+ // Default: enter plan mode (if not already) and show the current plan.
2450
+ const current = session.permissionContext.mode;
2451
+ if (current !== 'plan') {
2452
+ await session.setPermissionContext({ mode: 'plan', permissions: [], approver });
2453
+ appendStatic([...formatInfoLine('plan mode on — mutating tools blocked; research, then ExitPlanMode'), '']);
2454
+ }
2455
+ const plan = readPlanFile(sdk.config.workDir);
2456
+ if (plan) {
2457
+ appendStatic([
2458
+ `${A.bold}Current plan${A.reset} ${A.dim}(${planFilePath(sdk.config.workDir)})${A.reset}`,
2459
+ '',
2460
+ ...renderRichText(plan, screen.width),
2461
+ '',
2462
+ ]);
2463
+ }
2464
+ else {
2465
+ appendStatic([...formatInfoLine('no plan yet — ask the agent to plan a task (it will call ExitPlanMode)'), '']);
2466
+ }
2467
+ return;
2468
+ }
2469
+ case 'rewind': {
2470
+ const n = parseInt(args, 10);
2471
+ if (!n || n < 1) {
2472
+ appendStatic([...formatErrorLine('usage: /rewind <N> — drops the last N messages (best-effort, no file restore)'), '']);
2473
+ return;
2474
+ }
2475
+ const msgs = session.messages;
2476
+ if (n >= msgs.length) {
2477
+ appendStatic([...formatErrorLine('cannot rewind beyond session start'), '']);
2478
+ return;
2479
+ }
2480
+ const kept = msgs.slice(0, msgs.length - n);
2481
+ // Create a fresh session with only the kept messages.
2482
+ const nextSession = await sdk.createSession({ title: session.title, model: session.model });
2483
+ if (kept.length > 0)
2484
+ await nextSession.appendMessages(kept);
2485
+ // Switch the active session.
2486
+ const prevSession = session;
2487
+ session = nextSession;
2488
+ await prevSession.delete().catch(() => undefined);
2489
+ appendStatic([...formatInfoLine(`rewound ${n} message${n === 1 ? '' : 's'} (session ${session.id}), files unchanged`), '']);
2490
+ return;
2491
+ }
1414
2492
  case 'sessions': {
1415
2493
  const sessions = await sdk.sessions.list();
1416
2494
  appendStatic([
@@ -1444,6 +2522,229 @@ export async function runActoviqTui(options = {}) {
1444
2522
  }
1445
2523
  return;
1446
2524
  }
2525
+ case 'context': {
2526
+ // Break down what is consuming the context window (gap #9 vs
2527
+ // claude-code's /context) — usage, messages, system prompt, tools,
2528
+ // the loaded CLAUDE.md sources, and the active config.
2529
+ const window = sdk.config.compact?.contextWindowTokens ?? 200_000;
2530
+ const used = lastTokenEstimate ?? 0;
2531
+ const pct = window > 0 ? Math.min(100, Math.round((used / window) * 100)) : 0;
2532
+ const usedK = used >= 1000 ? `${(used / 1000).toFixed(1)}k` : `${used}`;
2533
+ const windowK = window >= 1000 ? `${(window / 1000).toFixed(0)}k` : `${window}`;
2534
+ const ctxColor = pct >= 90 ? A.red : pct >= 70 ? A.yellow : A.dim;
2535
+ const messages = session.messages.length;
2536
+ const sysChars = systemPrompt.length;
2537
+ const mcpCount = toolMetadata.filter(t => t.provider === 'mcp').length;
2538
+ const project = loadProjectContext(sdk.config.workDir);
2539
+ const team = activeTeamName ?? 'none';
2540
+ const router = activeRouter ? activeRouter.name : 'off';
2541
+ const bridge = bridgeMode && activeBridgeConfig ? activeBridgeConfig.name : 'off';
2542
+ appendStatic([
2543
+ `${A.bold}Context window${A.reset}`,
2544
+ ` ${ctxColor}${pct}% used (${usedK} / ${windowK} tokens)${A.reset}`,
2545
+ ` ${A.dim}messages${A.reset} ${messages}`,
2546
+ ` ${A.dim}system prompt${A.reset} ~${sysChars} chars`,
2547
+ ` ${A.dim}tools${A.reset} ${toolMetadata.length}${mcpCount > 0 ? ` (${mcpCount} MCP)` : ''}`,
2548
+ ` ${A.dim}CLAUDE.md${A.reset} ${project.sources.length ? project.sources.join(', ') : '(none loaded)'}`,
2549
+ ` ${A.dim}active${A.reset} model=${session.model} · effort=${currentEffort() ?? 'auto'} · team=${team} · router=${router} · bridge=${bridge}`,
2550
+ '',
2551
+ ]);
2552
+ return;
2553
+ }
2554
+ case 'cost':
2555
+ case 'usage': {
2556
+ // Running token + spend totals for the session (gap #20).
2557
+ const fmtTok = (n) => n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`;
2558
+ const costStr = totalCostUsd === null
2559
+ ? `${A.dim}(unknown — model lacks pricing; set ~/.actoviq/pricing.json)${A.reset}`
2560
+ : `$${totalCostUsd.toFixed(4)}`;
2561
+ const lines = [
2562
+ `${A.bold}Session usage${A.reset}`,
2563
+ ` ${A.dim}tokens${A.reset} ${fmtTok(totalInputTokens)} in · ${fmtTok(totalOutputTokens)} out`,
2564
+ ` ${A.dim}cost${A.reset} ${costStr}`,
2565
+ ` ${A.dim}model${A.reset} ${session.model}`,
2566
+ ];
2567
+ // Per-config breakdown panel.
2568
+ if (configUsage.size > 0) {
2569
+ lines.push('');
2570
+ lines.push(`${A.bold}By config${A.reset}`);
2571
+ for (const [name, rec] of configUsage) {
2572
+ const active = activeBridgeConfig?.name === name;
2573
+ const star = active ? ` ${A.green}*${A.reset}` : '';
2574
+ const cfgCost = configCost(name, rec);
2575
+ lines.push(` ${A.bold}${name}${star}${A.reset} ${A.dim}${rec.turns} turn${rec.turns === 1 ? '' : 's'}${A.reset} ${fmtTok(rec.inputTokens)} in · ${fmtTok(rec.outputTokens)} out${cfgCost ? ` ${cfgCost}` : ''}`);
2576
+ }
2577
+ }
2578
+ lines.push('');
2579
+ appendStatic(lines);
2580
+ return;
2581
+ }
2582
+ case 'doctor': {
2583
+ // Configuration diagnostics (gap #21, partial). Checks the things a
2584
+ // user would actually need to fix to get a run working.
2585
+ const cfg = sdk.config;
2586
+ const ok = (b) => b ? `${A.green}✓${A.reset}` : `${A.red}✗${A.reset}`;
2587
+ const lines = [`${A.bold}Actoviq diagnostics${A.reset}`];
2588
+ // Model + provider
2589
+ lines.push(` ${ok(Boolean(cfg.model))} model ${A.dim}${cfg.model || '(unset)'}${A.reset}`);
2590
+ lines.push(` ${ok(Boolean(cfg.provider))} provider ${A.dim}${cfg.provider || '(unset)'}${A.reset}`);
2591
+ // API key (env or settings)
2592
+ const apiKey = cfg.apiKey ?? process.env.ACTOVIQ_API_KEY ?? process.env.ANTHROPIC_API_KEY ?? process.env.OPENAI_API_KEY;
2593
+ lines.push(` ${ok(Boolean(apiKey))} api key ${A.dim}${apiKey ? 'set (' + maskKey(apiKey) + ')' : '(not set — set ACTOVIQ_API_KEY or configure via /model config)'}${A.reset}`);
2594
+ if (cfg.baseURL)
2595
+ lines.push(` ${A.dim}base url${A.reset} ${cfg.baseURL}`);
2596
+ // Workspace + git
2597
+ let isGit = false;
2598
+ try {
2599
+ execSync('git rev-parse --is-inside-work-tree', { cwd: cfg.workDir, stdio: 'ignore' });
2600
+ isGit = true;
2601
+ }
2602
+ catch { /* not git */ }
2603
+ lines.push(` ${ok(true)} workdir ${A.dim}${cfg.workDir}${A.reset}`);
2604
+ lines.push(` ${ok(isGit)} git repo ${A.dim}${isGit ? 'yes' : 'no'}${A.reset}`);
2605
+ // Session + permissions
2606
+ lines.push(` ${ok(true)} session ${A.dim}${session.id}${A.reset} · ${session.messages.length} messages`);
2607
+ lines.push(` ${ok(true)} permission mode ${A.dim}${currentPermissionMode()}${A.reset}`);
2608
+ lines.push(` ${ok(toolMetadata.length > 0)} tools ${A.dim}${toolMetadata.length}${A.reset}`);
2609
+ // Context memory
2610
+ const project = loadProjectContext(cfg.workDir);
2611
+ lines.push(` ${ok(project.sources.length > 0)} CLAUDE.md ${A.dim}${project.sources.length ? project.sources.join(', ') : '(none)'}${A.reset}`);
2612
+ // Bridge runtimes
2613
+ const detections = await withSpinner('detecting runtimes', detectBridgeProviders);
2614
+ const avail = detections.filter(d => d.available);
2615
+ lines.push(` ${ok(avail.length > 0)} bridge runtimes ${A.dim}${avail.length ? avail.map(d => d.id).join(', ') : '(none on PATH)'}${A.reset}`);
2616
+ if (bridgeMode && activeBridgeConfig) {
2617
+ lines.push(` ${A.dim}active bridge${A.reset} ${activeBridgeConfig.name}${bridgeModelLabel ? ` · ${bridgeModelLabel}` : ''}`);
2618
+ }
2619
+ lines.push('');
2620
+ appendStatic(lines);
2621
+ return;
2622
+ }
2623
+ case 'batch': {
2624
+ const file = args.trim();
2625
+ if (!file) {
2626
+ appendStatic([...formatErrorLine('usage: /batch <file> — runs each line as a separate turn'), '']);
2627
+ return;
2628
+ }
2629
+ let prompts;
2630
+ try {
2631
+ const content = fs.readFileSync(path.resolve(workDir, file), 'utf-8');
2632
+ prompts = content.split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('#'));
2633
+ }
2634
+ catch (error) {
2635
+ appendStatic([...formatErrorLine(`cannot read batch file: ${error.message}`), '']);
2636
+ return;
2637
+ }
2638
+ if (!prompts.length) {
2639
+ appendStatic([...formatInfoLine('batch file is empty'), '']);
2640
+ return;
2641
+ }
2642
+ appendStatic([...formatInfoLine(`batch: ${prompts.length} prompt${prompts.length === 1 ? '' : 's'} from ${file}`), '']);
2643
+ for (let i = 0; i < prompts.length; i++) {
2644
+ appendStatic([`${A.dim}[${i + 1}/${prompts.length}]${A.reset} ${A.bold}>${A.reset} ${truncateToWidth(prompts[i], 60)}`, '']);
2645
+ await startRun(prompts[i]);
2646
+ }
2647
+ appendStatic([...formatInfoLine(`batch complete — ${prompts.length} prompt${prompts.length === 1 ? '' : 's'} done`), '']);
2648
+ return;
2649
+ }
2650
+ case 'goal': {
2651
+ const arg = args.trim();
2652
+ if (!arg) {
2653
+ const g = getGoal();
2654
+ if (!g) {
2655
+ appendStatic([...formatInfoLine('no goal set — use /goal <objective> to set one'), '']);
2656
+ }
2657
+ else {
2658
+ const marks = { active: `${A.green}▶ active${A.reset}`, paused: `${A.yellow}‖ paused${A.reset}`, complete: `${A.dim}✓ complete${A.reset}` };
2659
+ appendStatic([`${A.bold}Goal${A.reset} ${marks[g.status]} ${A.dim}${g.setAt.slice(0, 10)}${A.reset}`, ` ${g.objective}`, '']);
2660
+ }
2661
+ return;
2662
+ }
2663
+ if (arg === 'clear') {
2664
+ await clearGoal();
2665
+ appendStatic([...formatInfoLine('goal cleared'), '']);
2666
+ return;
2667
+ }
2668
+ if (arg === 'pause') {
2669
+ const g = getGoal();
2670
+ if (g && g.status === 'active') {
2671
+ await session.mergeMetadata({ [GOAL_METADATA_KEY]: { ...g, status: 'paused' } });
2672
+ appendStatic([...formatInfoLine('goal paused'), '']);
2673
+ }
2674
+ else {
2675
+ appendStatic([...formatInfoLine('no active goal to pause'), '']);
2676
+ }
2677
+ return;
2678
+ }
2679
+ if (arg === 'resume') {
2680
+ const g = getGoal();
2681
+ if (g && g.status === 'paused') {
2682
+ await session.mergeMetadata({ [GOAL_METADATA_KEY]: { ...g, status: 'active' } });
2683
+ appendStatic([...formatInfoLine('goal resumed'), '']);
2684
+ }
2685
+ else {
2686
+ appendStatic([...formatInfoLine('no paused goal to resume'), '']);
2687
+ }
2688
+ return;
2689
+ }
2690
+ if (arg === 'complete' || arg === 'done') {
2691
+ const g = getGoal();
2692
+ if (g) {
2693
+ await session.mergeMetadata({ [GOAL_METADATA_KEY]: { ...g, status: 'complete' } });
2694
+ appendStatic([...formatInfoLine('goal marked complete'), '']);
2695
+ }
2696
+ return;
2697
+ }
2698
+ await setGoal(arg);
2699
+ appendStatic([...formatInfoLine(`goal set — ${arg}`), '']);
2700
+ return;
2701
+ }
2702
+ case 'review': {
2703
+ // Run a code-review prompt on the current git diff (gap #5 subset).
2704
+ let diff = '';
2705
+ try {
2706
+ diff = execSync('git diff', { cwd: workDir, encoding: 'utf8', maxBuffer: 1024 * 1024, timeout: 15_000 }).trim();
2707
+ }
2708
+ catch { /* no diff available */ }
2709
+ if (!diff) {
2710
+ appendStatic([...formatInfoLine('no uncommitted changes to review — working tree is clean'), '']);
2711
+ return;
2712
+ }
2713
+ await startRun('Review this code change for correctness bugs, security issues, and simplification opportunities. ' +
2714
+ 'File-by-file, note any real problems with file_path:line_number. Skip trivial style nits.\n\n```diff\n' +
2715
+ diff.slice(0, 80_000) + '\n```');
2716
+ return;
2717
+ }
2718
+ case 'stats': {
2719
+ const now = Date.now();
2720
+ const uptime = Math.max(0, Math.round((now - (runStartedAt || now)) / 1000));
2721
+ const fmtTok = (n) => n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`;
2722
+ appendStatic([
2723
+ `${A.bold}Session stats${A.reset}`,
2724
+ ` ${A.dim}messages${A.reset} ${session.messages.length}`,
2725
+ ` ${A.dim}tokens${A.reset} ${fmtTok(totalInputTokens)} in · ${fmtTok(totalOutputTokens)} out`,
2726
+ ` ${A.dim}tools${A.reset} ${toolMetadata.length}${toolMetadata.filter(t => t.provider === 'mcp').length ? ` (${toolMetadata.filter(t => t.provider === 'mcp').length} MCP)` : ''}`,
2727
+ ` ${A.dim}model${A.reset} ${session.model}${bridgeMode && activeBridgeConfig ? ` · bridge:${activeBridgeConfig.name}` : ''}`,
2728
+ ` ${A.dim}output style${A.reset} ${outputStyle}`,
2729
+ ` ${A.dim}plan mode${A.reset} ${session.permissionContext.mode === 'plan' ? 'on' : 'off'}`,
2730
+ '',
2731
+ ]);
2732
+ return;
2733
+ }
2734
+ case 'export': {
2735
+ const file = args.trim() || `session-${new Date().toISOString().replace(/[:.]/g, '-')}.md`;
2736
+ const md = session.messages
2737
+ .map((m) => `## ${m.role === 'user' ? 'User' : m.role === 'assistant' ? 'Assistant' : m.role}\n\n${typeof m.content === 'string' ? m.content : JSON.stringify(m.content)}`)
2738
+ .join('\n\n---\n\n');
2739
+ try {
2740
+ fs.writeFileSync(path.resolve(workDir, file), md, 'utf-8');
2741
+ appendStatic([...formatInfoLine(`conversation exported to ${file}`), '']);
2742
+ }
2743
+ catch (error) {
2744
+ appendStatic([...formatErrorLine(`export failed: ${error.message}`), '']);
2745
+ }
2746
+ return;
2747
+ }
1447
2748
  case 'compact': {
1448
2749
  try {
1449
2750
  const summaryInstructions = args || undefined;
@@ -1486,6 +2787,43 @@ export async function runActoviqTui(options = {}) {
1486
2787
  case 'mcp':
1487
2788
  await showMcp();
1488
2789
  return;
2790
+ case 'hooks': {
2791
+ // List configured hooks from settings.json (gap #2). Hooks are read
2792
+ // live from the hooks.PreToolUse[], hooks.PostToolUse[], and
2793
+ // hooks.SessionStart[] blocks.
2794
+ const preHooks = readPreToolUseHooks(getLoadedJsonConfig()?.raw);
2795
+ const postHooks = readPostToolUseHooks(getLoadedJsonConfig()?.raw);
2796
+ const startHooks = readSessionStartHooks(getLoadedJsonConfig()?.raw);
2797
+ const total = preHooks.length + postHooks.length + startHooks.length;
2798
+ if (total === 0) {
2799
+ appendStatic([
2800
+ ...formatInfoLine('no hooks configured'),
2801
+ ...formatInfoLine('add to ~/.actoviq/settings.json: "{ hooks: { PreToolUse: [...], PostToolUse: [...], SessionStart: [...] } }"'),
2802
+ ` ${A.dim}PreToolUse: { "matcher": "Bash", "command": "echo checking $ACTOVIQ_HOOK_TOOL" }${A.reset}`,
2803
+ ` ${A.dim}PostToolUse: { "matcher": "*", "command": "notify-tool-complete" } (fire-and-forget)${A.reset}`,
2804
+ ` ${A.dim}SessionStart: { "command": "echo session started" } (fire-and-forget)${A.reset}`,
2805
+ '',
2806
+ ]);
2807
+ }
2808
+ else {
2809
+ const lines = [`${A.bold}Hooks${A.reset} ${A.dim}(${total})${A.reset}`];
2810
+ if (preHooks.length > 0) {
2811
+ lines.push(`${A.bold} PreToolUse${A.reset} ${A.dim}(${preHooks.length}) — blocks tool on non-zero exit or "BLOCK" stdout${A.reset}`);
2812
+ preHooks.forEach((h, i) => lines.push(` ${A.dim}${i + 1}.${A.reset} ${A.bold}${h.matcher}${A.reset} ${A.dim}→${A.reset} ${truncateToWidth(h.command, 50)}`));
2813
+ }
2814
+ if (postHooks.length > 0) {
2815
+ lines.push(`${A.bold} PostToolUse${A.reset} ${A.dim}(${postHooks.length}) — fire-and-forget after tool completes${A.reset}`);
2816
+ postHooks.forEach((h, i) => lines.push(` ${A.dim}${i + 1}.${A.reset} ${A.bold}${h.matcher}${A.reset} ${A.dim}→${A.reset} ${truncateToWidth(h.command, 50)}`));
2817
+ }
2818
+ if (startHooks.length > 0) {
2819
+ lines.push(`${A.bold} SessionStart${A.reset} ${A.dim}(${startHooks.length}) — fire-and-forget on session init${A.reset}`);
2820
+ startHooks.forEach((h, i) => lines.push(` ${A.dim}${i + 1}.${A.reset} ${A.dim}→${A.reset} ${truncateToWidth(h.command, 50)}`));
2821
+ }
2822
+ lines.push('');
2823
+ appendStatic(lines);
2824
+ }
2825
+ return;
2826
+ }
1489
2827
  case 'plugins':
1490
2828
  await showPlugins();
1491
2829
  return;
@@ -1625,6 +2963,49 @@ export async function runActoviqTui(options = {}) {
1625
2963
  appendStatic([...formatInfoLine('usage: /worktree [enter <name>|exit|list]'), '']);
1626
2964
  return;
1627
2965
  }
2966
+ case 'bridge': {
2967
+ if (args === 'run' || args.startsWith('run ')) {
2968
+ const bp = args.startsWith('run ') ? args.slice(4).trim() : '';
2969
+ if (!bp) {
2970
+ appendStatic([...formatErrorLine('usage: /bridge run <prompt>'), '']);
2971
+ return;
2972
+ }
2973
+ await runBridgePrompt(bp);
2974
+ return;
2975
+ }
2976
+ if (args === 'switch' || args.startsWith('switch ')) {
2977
+ const target = args.startsWith('switch ') ? args.slice(7).trim() : '';
2978
+ await switchBridgeProvider(target);
2979
+ return;
2980
+ }
2981
+ if (args === 'setup') {
2982
+ await configureBridgeSettings();
2983
+ return;
2984
+ }
2985
+ if (args === 'config') {
2986
+ await manageBridgeConfigs();
2987
+ return;
2988
+ }
2989
+ if (args === 'off') {
2990
+ await disableBridge();
2991
+ return;
2992
+ }
2993
+ if (args === 'model' || args.startsWith('model ')) {
2994
+ const modelId = args.startsWith('model ') ? args.slice(6).trim() : '';
2995
+ await selectBridgeModel(modelId);
2996
+ return;
2997
+ }
2998
+ if (args === 'help') {
2999
+ printBridgeHelp();
3000
+ return;
3001
+ }
3002
+ if (!args) {
3003
+ await openBridgeBoard();
3004
+ return;
3005
+ }
3006
+ await configureBridgeSettings();
3007
+ return;
3008
+ }
1628
3009
  // ── v0.5.0: Model Team ───────────────────────────────────
1629
3010
  case 'team': {
1630
3011
  if (args.startsWith('ask ')) {
@@ -1772,13 +3153,13 @@ export async function runActoviqTui(options = {}) {
1772
3153
  return;
1773
3154
  const name = key.name ?? '';
1774
3155
  if (name === 'up') {
1775
- dialog.selected = (dialog.selected + 2) % 3;
3156
+ dialog.selected = (dialog.selected + 3) % 4;
1776
3157
  }
1777
3158
  else if (name === 'down' || name === 'tab') {
1778
- dialog.selected = (dialog.selected + 1) % 3;
3159
+ dialog.selected = (dialog.selected + 1) % 4;
1779
3160
  }
1780
3161
  else if (name === 'return' || name === 'enter') {
1781
- dialog.resolve(dialog.selected === 0 ? 'allow' : dialog.selected === 1 ? 'always' : 'deny');
3162
+ dialog.resolve(dialog.selected === 0 ? 'allow' : dialog.selected === 1 ? 'always' : dialog.selected === 2 ? 'always-user' : 'deny');
1782
3163
  return;
1783
3164
  }
1784
3165
  else if (name === 'y') {
@@ -2121,6 +3502,7 @@ export async function runActoviqTui(options = {}) {
2121
3502
  catch {
2122
3503
  // best-effort close
2123
3504
  }
3505
+ // Close any live bridge runtime clients (sessions were per-provider).
2124
3506
  process.exit(code);
2125
3507
  }
2126
3508
  screen.start();
@@ -2146,6 +3528,7 @@ export async function runActoviqTui(options = {}) {
2146
3528
  }
2147
3529
  });
2148
3530
  process.on('SIGTERM', () => void shutdown(0));
3531
+ process.on('SIGINT', () => void shutdown(0));
2149
3532
  process.stdout.on('resize', () => renderDynamic());
2150
3533
  // Keep the process alive until shutdown() exits it.
2151
3534
  await new Promise(() => { });