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.
- package/README-zh.md +58 -62
- package/README.md +72 -22
- package/assets/actoviq-icon.png +0 -0
- package/bin/actoviq-gui.js +24 -0
- package/dist/src/cli/actoviq-react.js +31 -14
- package/dist/src/cli/actoviq-react.js.map +1 -1
- package/dist/src/cli/actoviq-tui.js +9 -0
- package/dist/src/cli/actoviq-tui.js.map +1 -1
- package/dist/src/cli/bridge-interactive-agent.js +94 -15
- package/dist/src/cli/bridge-interactive-agent.js.map +1 -1
- package/dist/src/cli/version.d.ts +3 -0
- package/dist/src/cli/version.d.ts.map +1 -0
- package/dist/src/cli/version.js +28 -0
- package/dist/src/cli/version.js.map +1 -0
- package/dist/src/gui/actoviqGui.d.ts +29 -0
- package/dist/src/gui/actoviqGui.d.ts.map +1 -0
- package/dist/src/gui/actoviqGui.js +5319 -0
- package/dist/src/gui/actoviqGui.js.map +1 -0
- package/dist/src/gui/electronMain.d.ts +3 -0
- package/dist/src/gui/electronMain.d.ts.map +1 -0
- package/dist/src/gui/electronMain.js +176 -0
- package/dist/src/gui/electronMain.js.map +1 -0
- package/dist/src/gui/guiMarkdown.d.ts +3 -0
- package/dist/src/gui/guiMarkdown.d.ts.map +1 -0
- package/dist/src/gui/guiMarkdown.js +188 -0
- package/dist/src/gui/guiMarkdown.js.map +1 -0
- package/dist/src/hooks/userHooks.d.ts +39 -0
- package/dist/src/hooks/userHooks.d.ts.map +1 -0
- package/dist/src/hooks/userHooks.js +169 -0
- package/dist/src/hooks/userHooks.js.map +1 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +2 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcp/mcpServerConfig.d.ts +19 -0
- package/dist/src/mcp/mcpServerConfig.d.ts.map +1 -0
- package/dist/src/mcp/mcpServerConfig.js +51 -0
- package/dist/src/mcp/mcpServerConfig.js.map +1 -0
- package/dist/src/memory/projectContext.d.ts +32 -0
- package/dist/src/memory/projectContext.d.ts.map +1 -0
- package/dist/src/memory/projectContext.js +175 -0
- package/dist/src/memory/projectContext.js.map +1 -0
- package/dist/src/memory/sessionHistory.d.ts +21 -0
- package/dist/src/memory/sessionHistory.d.ts.map +1 -0
- package/dist/src/memory/sessionHistory.js +83 -0
- package/dist/src/memory/sessionHistory.js.map +1 -0
- package/dist/src/parity/actoviqBridgeSdk.d.ts +3 -0
- package/dist/src/parity/actoviqBridgeSdk.d.ts.map +1 -1
- package/dist/src/parity/actoviqBridgeSdk.js +93 -92
- package/dist/src/parity/actoviqBridgeSdk.js.map +1 -1
- package/dist/src/parity/bridgeConfigs.d.ts +49 -0
- package/dist/src/parity/bridgeConfigs.d.ts.map +1 -0
- package/dist/src/parity/bridgeConfigs.js +134 -0
- package/dist/src/parity/bridgeConfigs.js.map +1 -0
- package/dist/src/parity/bridgeEventAdapter.d.ts +20 -0
- package/dist/src/parity/bridgeEventAdapter.d.ts.map +1 -0
- package/dist/src/parity/bridgeEventAdapter.js +121 -0
- package/dist/src/parity/bridgeEventAdapter.js.map +1 -0
- package/dist/src/parity/bridgeExecResolver.d.ts +27 -0
- package/dist/src/parity/bridgeExecResolver.d.ts.map +1 -0
- package/dist/src/parity/bridgeExecResolver.js +95 -0
- package/dist/src/parity/bridgeExecResolver.js.map +1 -0
- package/dist/src/parity/bridgeProviders.d.ts +132 -0
- package/dist/src/parity/bridgeProviders.d.ts.map +1 -0
- package/dist/src/parity/bridgeProviders.js +651 -0
- package/dist/src/parity/bridgeProviders.js.map +1 -0
- package/dist/src/prompts/outputStyles.d.ts +20 -0
- package/dist/src/prompts/outputStyles.d.ts.map +1 -0
- package/dist/src/prompts/outputStyles.js +35 -0
- package/dist/src/prompts/outputStyles.js.map +1 -0
- package/dist/src/prompts/systemPrompt.d.ts.map +1 -1
- package/dist/src/prompts/systemPrompt.js +9 -1
- package/dist/src/prompts/systemPrompt.js.map +1 -1
- package/dist/src/router/modelRouter.d.ts +7 -1
- package/dist/src/router/modelRouter.d.ts.map +1 -1
- package/dist/src/router/modelRouter.js +50 -15
- package/dist/src/router/modelRouter.js.map +1 -1
- package/dist/src/runtime/agentSession.d.ts +6 -0
- package/dist/src/runtime/agentSession.d.ts.map +1 -1
- package/dist/src/runtime/agentSession.js +15 -0
- package/dist/src/runtime/agentSession.js.map +1 -1
- package/dist/src/runtime/bashClassification.d.ts +18 -0
- package/dist/src/runtime/bashClassification.d.ts.map +1 -0
- package/dist/src/runtime/bashClassification.js +83 -0
- package/dist/src/runtime/bashClassification.js.map +1 -0
- package/dist/src/team/modelTeam.d.ts +3 -5
- package/dist/src/team/modelTeam.d.ts.map +1 -1
- package/dist/src/team/modelTeam.js +98 -162
- package/dist/src/team/modelTeam.js.map +1 -1
- package/dist/src/team/teamRuntime.d.ts +60 -0
- package/dist/src/team/teamRuntime.d.ts.map +1 -0
- package/dist/src/team/teamRuntime.js +139 -0
- package/dist/src/team/teamRuntime.js.map +1 -0
- package/dist/src/tools/actoviqCoreTools.d.ts +1 -0
- package/dist/src/tools/actoviqCoreTools.d.ts.map +1 -1
- package/dist/src/tools/actoviqCoreTools.js +5 -0
- package/dist/src/tools/actoviqCoreTools.js.map +1 -1
- package/dist/src/tools/planMode/PlanModeTools.d.ts +18 -0
- package/dist/src/tools/planMode/PlanModeTools.d.ts.map +1 -0
- package/dist/src/tools/planMode/PlanModeTools.js +71 -0
- package/dist/src/tools/planMode/PlanModeTools.js.map +1 -0
- package/dist/src/tui/actoviqTui.d.ts +3 -0
- package/dist/src/tui/actoviqTui.d.ts.map +1 -1
- package/dist/src/tui/actoviqTui.js +1474 -91
- package/dist/src/tui/actoviqTui.js.map +1 -1
- package/dist/src/tui/transcript.d.ts +6 -0
- package/dist/src/tui/transcript.d.ts.map +1 -1
- package/dist/src/tui/transcript.js +27 -0
- package/dist/src/tui/transcript.js.map +1 -1
- package/dist/src/types.d.ts +128 -9
- package/dist/src/types.d.ts.map +1 -1
- package/dist/src/ui/commandSurface.d.ts +13 -0
- package/dist/src/ui/commandSurface.d.ts.map +1 -0
- package/dist/src/ui/commandSurface.js +100 -0
- package/dist/src/ui/commandSurface.js.map +1 -0
- 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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
169
|
-
let toolMetadata
|
|
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
|
|
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:
|
|
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
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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', `
|
|
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
|
|
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
|
|
758
|
-
//
|
|
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
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1236
|
-
|
|
1237
|
-
label: server,
|
|
1238
|
-
|
|
1239
|
-
|
|
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 (
|
|
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}${
|
|
1245
|
-
`${A.dim}${(byServer.get(
|
|
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 +
|
|
3156
|
+
dialog.selected = (dialog.selected + 3) % 4;
|
|
1776
3157
|
}
|
|
1777
3158
|
else if (name === 'down' || name === 'tab') {
|
|
1778
|
-
dialog.selected = (dialog.selected + 1) %
|
|
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(() => { });
|