tuna-agent 0.1.0 → 0.1.1
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/dist/agents/claude-code-adapter.d.ts +3 -1
- package/dist/agents/claude-code-adapter.js +28 -4
- package/dist/agents/factory.d.ts +2 -1
- package/dist/agents/factory.js +2 -2
- package/dist/cli/commands/extension.d.ts +10 -0
- package/dist/cli/commands/extension.js +86 -0
- package/dist/cli/index.js +12 -0
- package/dist/daemon/index.js +114 -44
- package/dist/daemon/ws-client.d.ts +12 -8
- package/dist/daemon/ws-client.js +23 -62
- package/dist/mcp/knowledge-server.d.ts +11 -0
- package/dist/mcp/knowledge-server.js +238 -0
- package/dist/mcp/setup.d.ts +20 -0
- package/dist/mcp/setup.js +84 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/utils/claude-cli.d.ts +2 -0
- package/dist/utils/claude-cli.js +11 -0
- package/dist/utils/message-schemas.d.ts +4 -1
- package/dist/utils/message-schemas.js +6 -1
- package/package.json +1 -1
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import type { AgentAdapter, AgentType } from './types.js';
|
|
2
|
-
import type { TaskAssignment, InputResponse } from '../types/index.js';
|
|
2
|
+
import type { AgentConfig, TaskAssignment, InputResponse } from '../types/index.js';
|
|
3
3
|
import type { AgentWebSocketClient } from '../daemon/ws-client.js';
|
|
4
4
|
export declare class ClaudeCodeAdapter implements AgentAdapter {
|
|
5
5
|
readonly type: AgentType;
|
|
6
6
|
readonly displayName = "Claude Code";
|
|
7
|
+
private readonly agentConfig;
|
|
8
|
+
constructor(config: AgentConfig);
|
|
7
9
|
checkHealth(): Promise<{
|
|
8
10
|
ok: boolean;
|
|
9
11
|
message: string;
|
|
@@ -7,9 +7,14 @@ import { planTask, chatWithPM } from '../pm/planner.js';
|
|
|
7
7
|
import { savePMState, clearPMState } from '../daemon/pm-state.js';
|
|
8
8
|
import { simplifyMarkdown, waitForInput, sessionToPayload, executePlanAndReport, } from '../utils/execution-helpers.js';
|
|
9
9
|
import { downloadAttachments, cleanupAttachments } from '../utils/image-download.js';
|
|
10
|
+
import { writeAgentFolderMcpConfig } from '../mcp/setup.js';
|
|
10
11
|
export class ClaudeCodeAdapter {
|
|
11
12
|
type = 'claude-code';
|
|
12
13
|
displayName = 'Claude Code';
|
|
14
|
+
agentConfig;
|
|
15
|
+
constructor(config) {
|
|
16
|
+
this.agentConfig = config;
|
|
17
|
+
}
|
|
13
18
|
async checkHealth() {
|
|
14
19
|
try {
|
|
15
20
|
execSync('which claude', { stdio: 'ignore' });
|
|
@@ -72,17 +77,24 @@ export class ClaudeCodeAdapter {
|
|
|
72
77
|
const cwd = task.repoPath || defaultWorkspace;
|
|
73
78
|
if (!fs.existsSync(cwd))
|
|
74
79
|
fs.mkdirSync(cwd, { recursive: true });
|
|
80
|
+
// Write MCP config to agent folder .claude/settings.json on first round
|
|
81
|
+
// Claude Code reads settings.json automatically (--mcp-config flag is unreliable)
|
|
82
|
+
if (round === 0) {
|
|
83
|
+
writeAgentFolderMcpConfig(cwd, this.agentConfig);
|
|
84
|
+
}
|
|
85
|
+
// Skills/scheduled tasks use MCP tools (knowledge sync) → disable agentTeam
|
|
86
|
+
// Manual chat tasks may spawn sub-agents → keep agentTeam enabled
|
|
87
|
+
const useAgentTeam = task.source !== 'skill' && task.source !== 'scheduled';
|
|
75
88
|
const result = await runClaude({
|
|
76
89
|
prompt: userMessage,
|
|
77
90
|
cwd,
|
|
78
|
-
allowedTools: ['Read', 'Edit', 'Write', 'Bash', 'Glob', 'Grep'],
|
|
79
91
|
outputFormat: 'stream-json',
|
|
80
92
|
includePartialMessages: true,
|
|
81
|
-
agentTeam: true,
|
|
82
93
|
maxTurns: 50,
|
|
83
94
|
resumeSessionId: round > 0 ? sessionId : undefined,
|
|
84
95
|
signal,
|
|
85
96
|
inputFiles: currentInputFiles,
|
|
97
|
+
agentTeam: useAgentTeam,
|
|
86
98
|
...(confirmBeforeEdit && onPermissionRequest ? {
|
|
87
99
|
permissionMode: 'default',
|
|
88
100
|
onPermissionRequest: (tool, detail) => onPermissionRequest('agent-team', tool, detail),
|
|
@@ -123,10 +135,22 @@ export class ClaudeCodeAdapter {
|
|
|
123
135
|
return;
|
|
124
136
|
}
|
|
125
137
|
// Log non-stream events for debugging
|
|
126
|
-
|
|
127
|
-
|
|
138
|
+
if (data.type === 'system') {
|
|
139
|
+
console.log(`[ClaudeCode] system: ${JSON.stringify(data).slice(0, 300)}`);
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
console.log(`[ClaudeCode] stream line: type=${data.type}${data.type === 'assistant' ? ' (assistant msg)' : ''}`);
|
|
143
|
+
}
|
|
144
|
+
// Track session ID + log MCP tools from init
|
|
128
145
|
if (data.type === 'system' && data.subtype === 'init') {
|
|
129
146
|
sessionId = data.session_id;
|
|
147
|
+
const initData = data;
|
|
148
|
+
// tools can be string[] or object[] depending on Claude Code version
|
|
149
|
+
const rawTools = initData.tools;
|
|
150
|
+
const toolNames = rawTools?.map(t => typeof t === 'string' ? t : String(t?.name ?? '')) ?? [];
|
|
151
|
+
const mcpTools = toolNames.filter(n => n.startsWith('mcp__'));
|
|
152
|
+
console.log(`[ClaudeCode] Init tools: total=${toolNames.length}, mcp=${mcpTools.length} (${mcpTools.join(', ') || 'none'})`);
|
|
153
|
+
console.log(`[ClaudeCode] All tools: ${toolNames.join(', ')}`);
|
|
130
154
|
}
|
|
131
155
|
// Send tool usage to activity tab
|
|
132
156
|
if (data.type === 'assistant' && data.message) {
|
package/dist/agents/factory.d.ts
CHANGED
|
@@ -1,2 +1,3 @@
|
|
|
1
1
|
import type { AgentAdapter, AgentConfig } from './types.js';
|
|
2
|
-
|
|
2
|
+
import type { AgentConfig as DaemonAgentConfig } from '../types/index.js';
|
|
3
|
+
export declare function createAgentAdapter(config: AgentConfig, daemonConfig?: DaemonAgentConfig): AgentAdapter;
|
package/dist/agents/factory.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { ClaudeCodeAdapter } from './claude-code-adapter.js';
|
|
2
2
|
import { OpenClawAdapter } from './openclaw-adapter.js';
|
|
3
|
-
export function createAgentAdapter(config) {
|
|
3
|
+
export function createAgentAdapter(config, daemonConfig) {
|
|
4
4
|
switch (config.type) {
|
|
5
5
|
case 'claude-code':
|
|
6
|
-
return new ClaudeCodeAdapter();
|
|
6
|
+
return new ClaudeCodeAdapter(daemonConfig);
|
|
7
7
|
case 'openclaw':
|
|
8
8
|
return new OpenClawAdapter(config);
|
|
9
9
|
default:
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* List all Chrome extensions paired with this machine.
|
|
3
|
+
* Usage: tuna-agent extension list
|
|
4
|
+
*/
|
|
5
|
+
export declare function extensionList(): Promise<void>;
|
|
6
|
+
/**
|
|
7
|
+
* Pair this machine with a Chrome extension using its pairing code.
|
|
8
|
+
* Usage: tuna-agent extension connect TUNA-XXXX-XXXX
|
|
9
|
+
*/
|
|
10
|
+
export declare function extensionConnect(code: string): Promise<void>;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { loadConfig, saveConfig } from '../../config/store.js';
|
|
3
|
+
/**
|
|
4
|
+
* List all Chrome extensions paired with this machine.
|
|
5
|
+
* Usage: tuna-agent extension list
|
|
6
|
+
*/
|
|
7
|
+
export async function extensionList() {
|
|
8
|
+
const config = loadConfig();
|
|
9
|
+
if (!config) {
|
|
10
|
+
console.error(chalk.red('Not connected. Run tuna-agent connect <CODE> first.'));
|
|
11
|
+
process.exit(1);
|
|
12
|
+
}
|
|
13
|
+
try {
|
|
14
|
+
const res = await fetch(`${config.apiUrl}/extension/list`, {
|
|
15
|
+
headers: { 'Authorization': `Bearer ${config.agentToken}` },
|
|
16
|
+
});
|
|
17
|
+
const body = await res.json();
|
|
18
|
+
if (!res.ok || !body.data) {
|
|
19
|
+
console.error(chalk.red(`Failed: ${body.message || 'Unknown error'}`));
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
const { extensions, total } = body.data;
|
|
23
|
+
console.log(chalk.cyan(`\nPaired Extensions (${total})\n`));
|
|
24
|
+
if (total === 0) {
|
|
25
|
+
console.log(chalk.gray(' No extensions paired yet.'));
|
|
26
|
+
console.log(`\n Run ${chalk.bold('tuna-agent extension connect <CODE>')} to pair one.`);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
for (const ext of extensions) {
|
|
30
|
+
const status = ext.connected ? chalk.green('● connected') : chalk.gray('○ offline');
|
|
31
|
+
console.log(` ${status} ${chalk.bold(ext.code)}`);
|
|
32
|
+
}
|
|
33
|
+
console.log('');
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
37
|
+
console.error(chalk.red(`\nFailed: ${msg}`));
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Pair this machine with a Chrome extension using its pairing code.
|
|
43
|
+
* Usage: tuna-agent extension connect TUNA-XXXX-XXXX
|
|
44
|
+
*/
|
|
45
|
+
export async function extensionConnect(code) {
|
|
46
|
+
const config = loadConfig();
|
|
47
|
+
if (!config) {
|
|
48
|
+
console.error(chalk.red('Not connected. Run tuna-agent connect <CODE> first.'));
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
const normalizedCode = code.trim().toUpperCase();
|
|
52
|
+
if (!normalizedCode.startsWith('TUNA-')) {
|
|
53
|
+
console.error(chalk.red('Invalid pair code. Expected format: TUNA-XXXX-XXXX'));
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
console.log(chalk.cyan('Pairing with Chrome extension...'));
|
|
57
|
+
console.log(` Code: ${chalk.bold(normalizedCode)}`);
|
|
58
|
+
try {
|
|
59
|
+
const res = await fetch(`${config.apiUrl}/extension/pair`, {
|
|
60
|
+
method: 'POST',
|
|
61
|
+
headers: {
|
|
62
|
+
'Content-Type': 'application/json',
|
|
63
|
+
'Authorization': `Bearer ${config.agentToken}`,
|
|
64
|
+
},
|
|
65
|
+
body: JSON.stringify({ code: normalizedCode }),
|
|
66
|
+
});
|
|
67
|
+
const body = await res.json();
|
|
68
|
+
if (!res.ok || !body.data) {
|
|
69
|
+
console.error(chalk.red(`\nFailed: ${body.message || 'Unknown error'}`));
|
|
70
|
+
process.exit(1);
|
|
71
|
+
}
|
|
72
|
+
console.log(chalk.green('\nExtension paired successfully!'));
|
|
73
|
+
console.log(` Machine: ${body.data.machineName}`);
|
|
74
|
+
console.log('\nThe Content Creator extension should now show your agent as connected.');
|
|
75
|
+
// Save code locally so agent can restore the pairing after server restart
|
|
76
|
+
const codes = new Set(config.extensionCodes || []);
|
|
77
|
+
codes.add(normalizedCode);
|
|
78
|
+
saveConfig({ ...config, extensionCodes: [...codes] });
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
82
|
+
console.error(chalk.red(`\nPairing failed: ${msg}`));
|
|
83
|
+
console.error(`Make sure tuna-agent daemon is configured (run tuna-agent connect first)`);
|
|
84
|
+
process.exit(1);
|
|
85
|
+
}
|
|
86
|
+
}
|
package/dist/cli/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import { connect } from './commands/connect.js';
|
|
|
4
4
|
import { start } from './commands/start.js';
|
|
5
5
|
import { stop } from './commands/stop.js';
|
|
6
6
|
import { status } from './commands/status.js';
|
|
7
|
+
import { extensionConnect, extensionList } from './commands/extension.js';
|
|
7
8
|
const program = new Command()
|
|
8
9
|
.name('tuna-agent')
|
|
9
10
|
.description('Tuna Agent - Run AI coding tasks on your machine')
|
|
@@ -29,4 +30,15 @@ program
|
|
|
29
30
|
.command('status')
|
|
30
31
|
.description('Show agent connection status')
|
|
31
32
|
.action(status);
|
|
33
|
+
const extensionCmd = program
|
|
34
|
+
.command('extension')
|
|
35
|
+
.description('Manage Chrome extension pairing');
|
|
36
|
+
extensionCmd
|
|
37
|
+
.command('connect <code>')
|
|
38
|
+
.description('Pair this agent with the Content Creator Chrome extension')
|
|
39
|
+
.action(extensionConnect);
|
|
40
|
+
extensionCmd
|
|
41
|
+
.command('list')
|
|
42
|
+
.description('List all Chrome extensions paired with this machine')
|
|
43
|
+
.action(extensionList);
|
|
32
44
|
program.parse();
|
package/dist/daemon/index.js
CHANGED
|
@@ -2,8 +2,7 @@ import fs from 'fs';
|
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import os from 'os';
|
|
4
4
|
import { AgentWebSocketClient } from './ws-client.js';
|
|
5
|
-
import { removePid } from '../config/store.js';
|
|
6
|
-
import { validatePath } from '../utils/validate-path.js';
|
|
5
|
+
import { removePid, loadConfig, saveConfig } from '../config/store.js';
|
|
7
6
|
import { validateMessage } from '../utils/message-schemas.js';
|
|
8
7
|
import { createAgentAdapter } from '../agents/factory.js';
|
|
9
8
|
import { loadPMState, savePMState, clearPMState } from './pm-state.js';
|
|
@@ -11,6 +10,8 @@ import { chatWithPM } from '../pm/planner.js';
|
|
|
11
10
|
import { executePlanAndReport, simplifyMarkdown, waitForInput } from '../utils/execution-helpers.js';
|
|
12
11
|
import { runClaude } from '../utils/claude-cli.js';
|
|
13
12
|
import { downloadAttachments, cleanupAttachments } from '../utils/image-download.js';
|
|
13
|
+
import { scanSkills } from '../utils/skill-scanner.js';
|
|
14
|
+
import { setupMcpConfig } from '../mcp/setup.js';
|
|
14
15
|
/**
|
|
15
16
|
* Start the agent daemon.
|
|
16
17
|
* Connects to API via WebSocket, receives tasks, delegates to agent adapter.
|
|
@@ -21,7 +22,14 @@ export async function startDaemon(config) {
|
|
|
21
22
|
type: config.agentType || 'claude-code',
|
|
22
23
|
...config.agentConfig,
|
|
23
24
|
};
|
|
24
|
-
const adapter = createAgentAdapter(agentConfig);
|
|
25
|
+
const adapter = createAgentAdapter(agentConfig, config);
|
|
26
|
+
// Setup MCP config for Knowledge server
|
|
27
|
+
try {
|
|
28
|
+
setupMcpConfig(config);
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
console.warn(`[Daemon] MCP config setup failed (non-fatal):`, err);
|
|
32
|
+
}
|
|
25
33
|
// Health check on startup
|
|
26
34
|
const health = await adapter.checkHealth();
|
|
27
35
|
if (!health.ok) {
|
|
@@ -58,8 +66,8 @@ export async function startDaemon(config) {
|
|
|
58
66
|
switch (type) {
|
|
59
67
|
case 'connected':
|
|
60
68
|
console.log(`[Daemon] Connected as "${msg.name}" (${msg.agentId})`);
|
|
61
|
-
// Recover
|
|
62
|
-
ws.send({ action: 'recover_orphaned_tasks' });
|
|
69
|
+
// Recover orphaned tasks — pass activeTaskId so API won't fail a task we're still running
|
|
70
|
+
ws.send({ action: 'recover_orphaned_tasks', activeTaskId: currentTaskId ?? undefined });
|
|
63
71
|
break;
|
|
64
72
|
case 'task_assigned': {
|
|
65
73
|
const task = msg.task;
|
|
@@ -76,6 +84,15 @@ export async function startDaemon(config) {
|
|
|
76
84
|
currentTaskId = task.id;
|
|
77
85
|
currentTaskAbort = new AbortController();
|
|
78
86
|
console.log(`[Daemon] Received task: ${task.id} — ${task.description.slice(0, 80)} (attachments: ${task.attachments?.length ?? 0})`);
|
|
87
|
+
// Update MCP config with the task's agent ID so knowledge server uses correct attribution
|
|
88
|
+
if (task.agentId) {
|
|
89
|
+
try {
|
|
90
|
+
setupMcpConfig({ ...config, agentId: task.agentId });
|
|
91
|
+
}
|
|
92
|
+
catch (err) {
|
|
93
|
+
console.warn(`[Daemon] MCP config update for task failed (non-fatal):`, err);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
79
96
|
try {
|
|
80
97
|
await adapter.handleTask(task, ws, pendingInputResolvers, currentTaskAbort.signal, pendingPermissionResolvers);
|
|
81
98
|
}
|
|
@@ -117,42 +134,21 @@ export async function startDaemon(config) {
|
|
|
117
134
|
}
|
|
118
135
|
case 'command': {
|
|
119
136
|
const command = msg.command;
|
|
120
|
-
if (command === '
|
|
121
|
-
const
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
const rawFolders = msg.agent_folders || [];
|
|
136
|
-
const agentFolders = rawFolders.map(f => f.startsWith('~') ? path.join(os.homedir(), f.slice(1)) : f);
|
|
137
|
-
console.log(`[Daemon] Scanning workspace: ${wsPath}, agent folders: ${agentFolders.length}`);
|
|
138
|
-
ws.setWorkspacePath(wsPath);
|
|
139
|
-
ws.setAgentFolders(agentFolders);
|
|
140
|
-
try {
|
|
141
|
-
const entries = fs.readdirSync(wsPath, { withFileTypes: true });
|
|
142
|
-
const projects = entries
|
|
143
|
-
.filter(e => e.isDirectory() && !e.name.startsWith('.'))
|
|
144
|
-
.map(e => ({
|
|
145
|
-
name: e.name,
|
|
146
|
-
path: path.join(wsPath, e.name),
|
|
147
|
-
}));
|
|
148
|
-
ws.send({ action: 'workspace_scanned', projects });
|
|
149
|
-
console.log(`[Daemon] Found ${projects.length} projects in workspace`);
|
|
150
|
-
}
|
|
151
|
-
catch (err) {
|
|
152
|
-
const errMsg = err instanceof Error ? err.message : String(err);
|
|
153
|
-
console.error(`[Daemon] Workspace scan failed: ${errMsg}`);
|
|
154
|
-
ws.send({ action: 'workspace_scanned', projects: [] });
|
|
155
|
-
}
|
|
137
|
+
if (command === 'rescan_agent_skills') {
|
|
138
|
+
const agentId = msg.agent_id;
|
|
139
|
+
const rawFolder = msg.folder_path || '';
|
|
140
|
+
const rawWsPath = msg.workspace_path || '~/tuna-workspace';
|
|
141
|
+
const wsPath = rawWsPath.startsWith('~')
|
|
142
|
+
? path.join(os.homedir(), rawWsPath.slice(1))
|
|
143
|
+
: rawWsPath;
|
|
144
|
+
const folder = rawFolder.startsWith('~')
|
|
145
|
+
? path.join(os.homedir(), rawFolder.slice(1))
|
|
146
|
+
: rawFolder;
|
|
147
|
+
console.log(`[Daemon] Rescan skills for agent ${agentId}, folder: ${folder || '(none)'}`);
|
|
148
|
+
const folders = folder ? [folder] : [];
|
|
149
|
+
const skills = scanSkills(wsPath, folders);
|
|
150
|
+
ws.send({ action: 'agent_skills_scanned', agent_id: agentId, skills });
|
|
151
|
+
console.log(`[Daemon] Scanned ${skills.length} skill(s) for agent ${agentId}`);
|
|
156
152
|
}
|
|
157
153
|
else if (command === 'analyze_skill') {
|
|
158
154
|
const skillId = msg.skill_id;
|
|
@@ -180,12 +176,15 @@ export async function startDaemon(config) {
|
|
|
180
176
|
const analyzePrompt = `You are analyzing a Claude Code skill file (markdown prompt template). Extract the following structured information from the content below:
|
|
181
177
|
|
|
182
178
|
1. **description**: A short summary of what this skill does in under 80 characters. Be very concise — like a subtitle, not a full sentence.
|
|
183
|
-
2. **actions**: Sub-commands or modes available in this skill. Look for sections, headings, or conditional logic that indicate different actions/modes the user can invoke. Each action has
|
|
184
|
-
|
|
179
|
+
2. **actions**: Sub-commands or modes available in this skill. Look for sections, headings, or conditional logic that indicate different actions/modes the user can invoke. Each action has:
|
|
180
|
+
- "name" (short, lowercase, no spaces — use hyphens)
|
|
181
|
+
- "description" (what it does, max 100 chars)
|
|
182
|
+
- "params" (array of parameter KEY strings that are relevant to THIS specific action — only include params that this action actually uses)
|
|
183
|
+
3. **parameters**: ALL input parameters the skill accepts (the full definitions). Look for {{param_name}} placeholders, $ARGUMENTS references, or documented inputs. Each parameter has "key", "label", "type" (text/number/select/multiline), "required" (boolean), "default_value", "options" (for select type), "placeholder".
|
|
185
184
|
- IMPORTANT: If a parameter has a finite set of known values (e.g. listed in config files, enums, documented choices), use type "select" and populate the "options" array with ALL known values. Only use type "text" when the input is truly free-form.
|
|
186
185
|
|
|
187
186
|
Respond with ONLY valid JSON, no markdown, no explanation:
|
|
188
|
-
{"description":"...","actions":[{"name":"...","description":"..."}],"parameters":[{"key":"...","label":"...","type":"select","required":false,"options":["opt1","opt2"]}]}
|
|
187
|
+
{"description":"...","actions":[{"name":"...","description":"...","params":["param_key1","param_key2"]}],"parameters":[{"key":"...","label":"...","type":"select","required":false,"options":["opt1","opt2"]}]}
|
|
189
188
|
|
|
190
189
|
If no actions are found, return an empty array. If no parameters found, return an empty array.
|
|
191
190
|
|
|
@@ -287,8 +286,79 @@ ${skillContent.slice(0, 15000)}`;
|
|
|
287
286
|
}
|
|
288
287
|
break;
|
|
289
288
|
}
|
|
289
|
+
case 'extension_task': {
|
|
290
|
+
// Task sent from Chrome extension — run Claude and stream results back
|
|
291
|
+
const extCode = msg.code;
|
|
292
|
+
const extTaskId = msg.taskId;
|
|
293
|
+
const extTask = msg.task;
|
|
294
|
+
const extStream = msg.stream;
|
|
295
|
+
if (!extCode || !extTaskId || !extTask) {
|
|
296
|
+
console.warn('[Daemon] extension_task missing required fields');
|
|
297
|
+
break;
|
|
298
|
+
}
|
|
299
|
+
console.log(`[Daemon] Extension task ${extTaskId}: ${extTask.substring(0, 80)}`);
|
|
300
|
+
// Run in background — don't block message handling
|
|
301
|
+
(async () => {
|
|
302
|
+
try {
|
|
303
|
+
let accumulated = '';
|
|
304
|
+
let progress = 0;
|
|
305
|
+
const result = await runClaude({
|
|
306
|
+
prompt: extTask,
|
|
307
|
+
cwd: os.homedir(),
|
|
308
|
+
maxTurns: 10,
|
|
309
|
+
outputFormat: extStream ? 'stream-json' : 'json',
|
|
310
|
+
lightweight: true,
|
|
311
|
+
timeoutMs: 120000,
|
|
312
|
+
onStreamLine: extStream ? (data) => {
|
|
313
|
+
if (data.type === 'stream_event') {
|
|
314
|
+
const event = data.event;
|
|
315
|
+
if (event?.type === 'content_block_delta') {
|
|
316
|
+
const delta = event.delta;
|
|
317
|
+
if (delta?.type === 'text_delta' && delta.text) {
|
|
318
|
+
const chunk = delta.text;
|
|
319
|
+
accumulated += chunk;
|
|
320
|
+
ws.send({ action: 'extension_task_stream', code: extCode, taskId: extTaskId, text: chunk });
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
} : undefined,
|
|
325
|
+
});
|
|
326
|
+
ws.send({
|
|
327
|
+
action: 'extension_task_done',
|
|
328
|
+
code: extCode,
|
|
329
|
+
taskId: extTaskId,
|
|
330
|
+
result: { script: result.result, text: result.result },
|
|
331
|
+
});
|
|
332
|
+
console.log(`[Daemon] Extension task ${extTaskId} done`);
|
|
333
|
+
}
|
|
334
|
+
catch (err) {
|
|
335
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
336
|
+
console.error(`[Daemon] Extension task ${extTaskId} error: ${errMsg}`);
|
|
337
|
+
ws.send({
|
|
338
|
+
action: 'extension_task_done',
|
|
339
|
+
code: extCode,
|
|
340
|
+
taskId: extTaskId,
|
|
341
|
+
result: { error: errMsg },
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
})();
|
|
345
|
+
break;
|
|
346
|
+
}
|
|
290
347
|
case 'pong':
|
|
291
348
|
break;
|
|
349
|
+
case 'extension_unpaired': {
|
|
350
|
+
// Extension signed out — remove this code from local config so it's not restored on reconnect
|
|
351
|
+
const unpairCode = msg.code;
|
|
352
|
+
if (unpairCode) {
|
|
353
|
+
const cfg = loadConfig();
|
|
354
|
+
if (cfg?.extensionCodes) {
|
|
355
|
+
const newCodes = cfg.extensionCodes.filter(c => c !== unpairCode);
|
|
356
|
+
saveConfig({ ...cfg, extensionCodes: newCodes });
|
|
357
|
+
console.log(`[Daemon] Extension unpaired: ${unpairCode}`);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
break;
|
|
361
|
+
}
|
|
292
362
|
case 'error':
|
|
293
363
|
console.error(`[Daemon] Server error: ${msg.message}`);
|
|
294
364
|
break;
|
|
@@ -10,9 +10,6 @@ export declare class AgentWebSocketClient {
|
|
|
10
10
|
private running;
|
|
11
11
|
private onMessage;
|
|
12
12
|
private onAuthFailed?;
|
|
13
|
-
private heartbeatCount;
|
|
14
|
-
private _workspacePath;
|
|
15
|
-
private _agentFolders;
|
|
16
13
|
constructor(config: AgentConfig, onMessage: MessageHandler, onAuthFailed?: (code: number, reason: string) => void);
|
|
17
14
|
connect(): void;
|
|
18
15
|
disconnect(): void;
|
|
@@ -94,14 +91,21 @@ export declare class AgentWebSocketClient {
|
|
|
94
91
|
context?: string;
|
|
95
92
|
startedAt?: string;
|
|
96
93
|
}): boolean;
|
|
94
|
+
/**
|
|
95
|
+
* Stream a text chunk back to the Chrome extension for an extension task.
|
|
96
|
+
*/
|
|
97
|
+
sendExtensionStream(code: string, taskId: string, text: string): boolean;
|
|
98
|
+
/**
|
|
99
|
+
* Signal that an extension task is complete.
|
|
100
|
+
*/
|
|
101
|
+
sendExtensionDone(code: string, taskId: string, result: Record<string, unknown>): boolean;
|
|
102
|
+
/**
|
|
103
|
+
* Send progress update for an extension task.
|
|
104
|
+
*/
|
|
105
|
+
sendExtensionProgress(code: string, taskId: string, progress: number): boolean;
|
|
97
106
|
get isConnected(): boolean;
|
|
98
107
|
private _connect;
|
|
99
108
|
private _startHeartbeat;
|
|
100
|
-
/** Set workspace path (called when scan_workspace command is received) */
|
|
101
|
-
setWorkspacePath(wsPath: string): void;
|
|
102
|
-
/** Set agent folder paths for skill scanning */
|
|
103
|
-
setAgentFolders(folders: string[]): void;
|
|
104
|
-
private _scanWorkspace;
|
|
105
109
|
private _stopHeartbeat;
|
|
106
110
|
private _scheduleReconnect;
|
|
107
111
|
}
|
package/dist/daemon/ws-client.js
CHANGED
|
@@ -1,9 +1,5 @@
|
|
|
1
|
-
import fs from 'fs';
|
|
2
|
-
import path from 'path';
|
|
3
|
-
import os from 'os';
|
|
4
1
|
import WebSocket from 'ws';
|
|
5
2
|
import { getSystemLoad, detectCapabilities } from '../system/info.js';
|
|
6
|
-
import { scanSkills } from '../utils/skill-scanner.js';
|
|
7
3
|
/** Close codes that indicate a permanent error — do NOT reconnect. */
|
|
8
4
|
const PERMANENT_CLOSE_CODES = new Set([
|
|
9
5
|
4001, // Authentication failed (invalid token)
|
|
@@ -19,9 +15,6 @@ export class AgentWebSocketClient {
|
|
|
19
15
|
running = false;
|
|
20
16
|
onMessage;
|
|
21
17
|
onAuthFailed;
|
|
22
|
-
heartbeatCount = 0;
|
|
23
|
-
_workspacePath = null;
|
|
24
|
-
_agentFolders = [];
|
|
25
18
|
constructor(config, onMessage, onAuthFailed) {
|
|
26
19
|
this.config = config;
|
|
27
20
|
this.onMessage = onMessage;
|
|
@@ -159,6 +152,24 @@ export class AgentWebSocketClient {
|
|
|
159
152
|
timestamp: message.startedAt || new Date().toISOString(),
|
|
160
153
|
});
|
|
161
154
|
}
|
|
155
|
+
/**
|
|
156
|
+
* Stream a text chunk back to the Chrome extension for an extension task.
|
|
157
|
+
*/
|
|
158
|
+
sendExtensionStream(code, taskId, text) {
|
|
159
|
+
return this.send({ action: 'extension_task_stream', code, taskId, text });
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Signal that an extension task is complete.
|
|
163
|
+
*/
|
|
164
|
+
sendExtensionDone(code, taskId, result) {
|
|
165
|
+
return this.send({ action: 'extension_task_done', code, taskId, result });
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Send progress update for an extension task.
|
|
169
|
+
*/
|
|
170
|
+
sendExtensionProgress(code, taskId, progress) {
|
|
171
|
+
return this.send({ action: 'extension_task_progress', code, taskId, progress });
|
|
172
|
+
}
|
|
162
173
|
get isConnected() {
|
|
163
174
|
return this.ws !== null && this.ws.readyState === WebSocket.OPEN;
|
|
164
175
|
}
|
|
@@ -177,6 +188,11 @@ export class AgentWebSocketClient {
|
|
|
177
188
|
this.reconnectAttempts = 0;
|
|
178
189
|
console.log('[WS] Connected to API server');
|
|
179
190
|
this._startHeartbeat();
|
|
191
|
+
// Restore extension pairings so server can send agent_connected to already-connected extensions
|
|
192
|
+
const codes = this.config.extensionCodes;
|
|
193
|
+
if (codes && codes.length > 0) {
|
|
194
|
+
this.send({ action: 'restore_extensions', codes });
|
|
195
|
+
}
|
|
180
196
|
});
|
|
181
197
|
this.ws.on('message', (raw) => {
|
|
182
198
|
try {
|
|
@@ -210,70 +226,15 @@ export class AgentWebSocketClient {
|
|
|
210
226
|
}
|
|
211
227
|
_startHeartbeat() {
|
|
212
228
|
this._stopHeartbeat();
|
|
213
|
-
this.heartbeatCount = 0;
|
|
214
|
-
// Scan workspace once on connect (delayed 3s to let connection stabilize)
|
|
215
|
-
setTimeout(() => this._scanWorkspace(), 3000);
|
|
216
229
|
this.heartbeatTimer = setInterval(() => {
|
|
217
|
-
this.heartbeatCount++;
|
|
218
230
|
const agentType = this.config.agentType || 'claude-code';
|
|
219
231
|
this.send({
|
|
220
232
|
action: 'heartbeat',
|
|
221
233
|
system_load: getSystemLoad(),
|
|
222
234
|
capabilities: detectCapabilities(agentType),
|
|
223
235
|
});
|
|
224
|
-
// Scan workspace every 20th heartbeat (~10 min)
|
|
225
|
-
if (this.heartbeatCount % 20 === 0) {
|
|
226
|
-
this._scanWorkspace();
|
|
227
|
-
}
|
|
228
236
|
}, 30000);
|
|
229
237
|
}
|
|
230
|
-
/** Set workspace path (called when scan_workspace command is received) */
|
|
231
|
-
setWorkspacePath(wsPath) {
|
|
232
|
-
this._workspacePath = wsPath;
|
|
233
|
-
}
|
|
234
|
-
/** Set agent folder paths for skill scanning */
|
|
235
|
-
setAgentFolders(folders) {
|
|
236
|
-
this._agentFolders = folders;
|
|
237
|
-
}
|
|
238
|
-
_scanWorkspace() {
|
|
239
|
-
const wsPath = this._workspacePath || path.join(os.homedir(), 'tuna-workspace');
|
|
240
|
-
try {
|
|
241
|
-
if (!fs.existsSync(wsPath))
|
|
242
|
-
return;
|
|
243
|
-
const entries = fs.readdirSync(wsPath, { withFileTypes: true });
|
|
244
|
-
const projects = entries
|
|
245
|
-
.filter(e => e.isDirectory() && !e.name.startsWith('.'))
|
|
246
|
-
.map(e => ({ name: e.name, path: path.join(wsPath, e.name) }));
|
|
247
|
-
this.send({ action: 'workspace_scanned', projects });
|
|
248
|
-
}
|
|
249
|
-
catch {
|
|
250
|
-
// Silent fail — non-critical
|
|
251
|
-
}
|
|
252
|
-
// Scan skills from .claude/commands/ directories
|
|
253
|
-
// Merge API-provided folders with local config scan_paths
|
|
254
|
-
try {
|
|
255
|
-
let folders = [...this._agentFolders];
|
|
256
|
-
if (folders.length === 0) {
|
|
257
|
-
// Fallback: read scan_paths from local config
|
|
258
|
-
const configPath = path.join(os.homedir(), '.tuna-agent', 'config.json');
|
|
259
|
-
try {
|
|
260
|
-
const cfg = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
261
|
-
if (Array.isArray(cfg.scan_paths)) {
|
|
262
|
-
folders = cfg.scan_paths;
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
catch { /* no config or parse error */ }
|
|
266
|
-
}
|
|
267
|
-
const skills = scanSkills(wsPath, folders);
|
|
268
|
-
if (skills.length > 0) {
|
|
269
|
-
this.send({ action: 'skills_scanned', skills });
|
|
270
|
-
console.log(`[WS] Scanned ${skills.length} skill(s) from ${folders.length} folder(s)`);
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
catch {
|
|
274
|
-
// Silent fail — non-critical
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
238
|
_stopHeartbeat() {
|
|
278
239
|
if (this.heartbeatTimer) {
|
|
279
240
|
clearInterval(this.heartbeatTimer);
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Knowledge MCP Server for Tuna Agent
|
|
4
|
+
*
|
|
5
|
+
* Stdio-based MCP server that exposes knowledge tools to Claude Code.
|
|
6
|
+
* Communicates with the Tuna API using agent token auth.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* node knowledge-server.js --api-url https://api.tuna.ai --token xxx --agent-id abc123
|
|
10
|
+
*/
|
|
11
|
+
export {};
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Knowledge MCP Server for Tuna Agent
|
|
4
|
+
*
|
|
5
|
+
* Stdio-based MCP server that exposes knowledge tools to Claude Code.
|
|
6
|
+
* Communicates with the Tuna API using agent token auth.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* node knowledge-server.js --api-url https://api.tuna.ai --token xxx --agent-id abc123
|
|
10
|
+
*/
|
|
11
|
+
function parseArgs() {
|
|
12
|
+
const args = process.argv.slice(2);
|
|
13
|
+
let apiUrl = '';
|
|
14
|
+
let token = '';
|
|
15
|
+
let agentId = '';
|
|
16
|
+
for (let i = 0; i < args.length; i++) {
|
|
17
|
+
if (args[i] === '--api-url' && args[i + 1])
|
|
18
|
+
apiUrl = args[++i];
|
|
19
|
+
else if (args[i] === '--token' && args[i + 1])
|
|
20
|
+
token = args[++i];
|
|
21
|
+
else if (args[i] === '--agent-id' && args[i + 1])
|
|
22
|
+
agentId = args[++i];
|
|
23
|
+
}
|
|
24
|
+
if (!apiUrl || !token || !agentId) {
|
|
25
|
+
process.stderr.write('Usage: knowledge-server --api-url URL --token TOKEN --agent-id ID\n');
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
return { apiUrl, token, agentId };
|
|
29
|
+
}
|
|
30
|
+
// ===== API Client =====
|
|
31
|
+
async function apiCall(config, method, path, body) {
|
|
32
|
+
const url = `${config.apiUrl}${path}`;
|
|
33
|
+
const headers = {
|
|
34
|
+
'Authorization': `Bearer ${config.token}`,
|
|
35
|
+
'Content-Type': 'application/json',
|
|
36
|
+
};
|
|
37
|
+
const options = { method, headers };
|
|
38
|
+
if (body)
|
|
39
|
+
options.body = JSON.stringify(body);
|
|
40
|
+
const res = await fetch(url, options);
|
|
41
|
+
const json = await res.json();
|
|
42
|
+
if (!res.ok || (json.code && json.code >= 400)) {
|
|
43
|
+
throw new Error(json.message || `API error: ${res.status}`);
|
|
44
|
+
}
|
|
45
|
+
return json.data;
|
|
46
|
+
}
|
|
47
|
+
function sendResponse(res) {
|
|
48
|
+
// Claude Code uses newline-delimited JSON (not Content-Length framing)
|
|
49
|
+
process.stdout.write(JSON.stringify(res) + '\n');
|
|
50
|
+
}
|
|
51
|
+
function sendResult(id, result) {
|
|
52
|
+
sendResponse({ jsonrpc: '2.0', id: id ?? null, result });
|
|
53
|
+
}
|
|
54
|
+
function sendError(id, code, message) {
|
|
55
|
+
sendResponse({ jsonrpc: '2.0', id: id ?? null, error: { code, message } });
|
|
56
|
+
}
|
|
57
|
+
// ===== Tool Definitions =====
|
|
58
|
+
const TOOLS = [
|
|
59
|
+
{
|
|
60
|
+
name: 'list_knowledge',
|
|
61
|
+
description: 'List all knowledge documents accessible to this agent. Returns document names, descriptions, and IDs.',
|
|
62
|
+
inputSchema: {
|
|
63
|
+
type: 'object',
|
|
64
|
+
properties: {},
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
name: 'read_knowledge',
|
|
69
|
+
description: 'Read the full content of a knowledge document by its ID. Returns the markdown content.',
|
|
70
|
+
inputSchema: {
|
|
71
|
+
type: 'object',
|
|
72
|
+
properties: {
|
|
73
|
+
knowledge_id: { type: 'string', description: 'The ID of the knowledge document to read' },
|
|
74
|
+
},
|
|
75
|
+
required: ['knowledge_id'],
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
name: 'create_knowledge',
|
|
80
|
+
description: 'Create a new knowledge document. Content should be in markdown format.',
|
|
81
|
+
inputSchema: {
|
|
82
|
+
type: 'object',
|
|
83
|
+
properties: {
|
|
84
|
+
name: { type: 'string', description: 'Name/title of the knowledge document' },
|
|
85
|
+
content: { type: 'string', description: 'Markdown content of the document' },
|
|
86
|
+
description: { type: 'string', description: 'Short description of what this document contains' },
|
|
87
|
+
},
|
|
88
|
+
required: ['name', 'content'],
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
name: 'update_knowledge',
|
|
93
|
+
description: 'Update an existing knowledge document. Can update name, description, and/or content.',
|
|
94
|
+
inputSchema: {
|
|
95
|
+
type: 'object',
|
|
96
|
+
properties: {
|
|
97
|
+
knowledge_id: { type: 'string', description: 'The ID of the knowledge document to update' },
|
|
98
|
+
name: { type: 'string', description: 'New name (optional)' },
|
|
99
|
+
content: { type: 'string', description: 'New markdown content (optional)' },
|
|
100
|
+
description: { type: 'string', description: 'New description (optional)' },
|
|
101
|
+
},
|
|
102
|
+
required: ['knowledge_id'],
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
];
|
|
106
|
+
// ===== Request Handler =====
|
|
107
|
+
async function handleRequest(config, req) {
|
|
108
|
+
try {
|
|
109
|
+
switch (req.method) {
|
|
110
|
+
case 'initialize':
|
|
111
|
+
sendResult(req.id, {
|
|
112
|
+
protocolVersion: '2024-11-05',
|
|
113
|
+
capabilities: { tools: {} },
|
|
114
|
+
serverInfo: { name: 'tuna-knowledge', version: '1.0.0' },
|
|
115
|
+
});
|
|
116
|
+
break;
|
|
117
|
+
case 'notifications/initialized':
|
|
118
|
+
// No response needed for notifications
|
|
119
|
+
break;
|
|
120
|
+
case 'tools/list':
|
|
121
|
+
sendResult(req.id, { tools: TOOLS });
|
|
122
|
+
break;
|
|
123
|
+
case 'tools/call': {
|
|
124
|
+
const toolName = req.params?.name ?? '';
|
|
125
|
+
const args = req.params?.arguments ?? {};
|
|
126
|
+
const result = await handleToolCall(config, toolName, args);
|
|
127
|
+
sendResult(req.id, result);
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
case 'ping':
|
|
131
|
+
sendResult(req.id, {});
|
|
132
|
+
break;
|
|
133
|
+
default:
|
|
134
|
+
if (req.id !== undefined) {
|
|
135
|
+
sendError(req.id, -32601, `Method not found: ${req.method}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
catch (err) {
|
|
140
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
141
|
+
if (req.id !== undefined) {
|
|
142
|
+
sendError(req.id, -32603, message);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
async function handleToolCall(config, toolName, args) {
|
|
147
|
+
try {
|
|
148
|
+
switch (toolName) {
|
|
149
|
+
case 'list_knowledge': {
|
|
150
|
+
const data = await apiCall(config, 'GET', `/agent-knowledge?agent_id=${config.agentId}`);
|
|
151
|
+
const items = data.items || [];
|
|
152
|
+
if (items.length === 0) {
|
|
153
|
+
return { content: [{ type: 'text', text: 'No knowledge documents found for this agent.' }] };
|
|
154
|
+
}
|
|
155
|
+
const listing = items.map((k) => `- **${k.name}** (ID: ${k._id})\n ${k.description || 'No description'}\n Size: ${k.file_size} bytes | Updated: ${k.updated_at}`).join('\n\n');
|
|
156
|
+
return { content: [{ type: 'text', text: `Found ${items.length} knowledge document(s):\n\n${listing}` }] };
|
|
157
|
+
}
|
|
158
|
+
case 'read_knowledge': {
|
|
159
|
+
if (!args.knowledge_id) {
|
|
160
|
+
return { content: [{ type: 'text', text: 'Error: knowledge_id is required' }], isError: true };
|
|
161
|
+
}
|
|
162
|
+
const data = await apiCall(config, 'GET', `/agent-knowledge/${args.knowledge_id}`);
|
|
163
|
+
return {
|
|
164
|
+
content: [{
|
|
165
|
+
type: 'text',
|
|
166
|
+
text: `# ${data.name}\n\n${data.description ? `> ${data.description}\n\n` : ''}${data.content}`,
|
|
167
|
+
}],
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
case 'create_knowledge': {
|
|
171
|
+
if (!args.name || !args.content) {
|
|
172
|
+
return { content: [{ type: 'text', text: 'Error: name and content are required' }], isError: true };
|
|
173
|
+
}
|
|
174
|
+
const data = await apiCall(config, 'POST', '/agent-knowledge', {
|
|
175
|
+
name: args.name,
|
|
176
|
+
content: args.content,
|
|
177
|
+
description: args.description || '',
|
|
178
|
+
agent_id: config.agentId,
|
|
179
|
+
});
|
|
180
|
+
return { content: [{ type: 'text', text: `Knowledge "${data.name}" created (ID: ${data._id})` }] };
|
|
181
|
+
}
|
|
182
|
+
case 'update_knowledge': {
|
|
183
|
+
if (!args.knowledge_id) {
|
|
184
|
+
return { content: [{ type: 'text', text: 'Error: knowledge_id is required' }], isError: true };
|
|
185
|
+
}
|
|
186
|
+
const body = {};
|
|
187
|
+
if (args.name)
|
|
188
|
+
body.name = args.name;
|
|
189
|
+
if (args.content)
|
|
190
|
+
body.content = args.content;
|
|
191
|
+
if (args.description)
|
|
192
|
+
body.description = args.description;
|
|
193
|
+
const data = await apiCall(config, 'PUT', `/agent-knowledge/${args.knowledge_id}`, body);
|
|
194
|
+
return { content: [{ type: 'text', text: `Knowledge "${data.name}" updated (ID: ${data._id})` }] };
|
|
195
|
+
}
|
|
196
|
+
default:
|
|
197
|
+
return { content: [{ type: 'text', text: `Unknown tool: ${toolName}` }], isError: true };
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
catch (err) {
|
|
201
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
202
|
+
return { content: [{ type: 'text', text: `Error: ${message}` }], isError: true };
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
// ===== Stdio Transport =====
|
|
206
|
+
function startServer(config) {
|
|
207
|
+
process.stderr.write(`[knowledge-mcp] Starting with agent ${config.agentId}\n`);
|
|
208
|
+
let buffer = '';
|
|
209
|
+
process.stdin.setEncoding('utf-8');
|
|
210
|
+
process.stdin.on('data', (chunk) => {
|
|
211
|
+
buffer += chunk;
|
|
212
|
+
// Claude Code sends newline-delimited JSON (one JSON object per line)
|
|
213
|
+
const lines = buffer.split('\n');
|
|
214
|
+
buffer = lines.pop() ?? ''; // Keep incomplete last line in buffer
|
|
215
|
+
for (const line of lines) {
|
|
216
|
+
const trimmed = line.trim();
|
|
217
|
+
if (!trimmed)
|
|
218
|
+
continue;
|
|
219
|
+
try {
|
|
220
|
+
const req = JSON.parse(trimmed);
|
|
221
|
+
handleRequest(config, req).catch((err) => {
|
|
222
|
+
process.stderr.write(`[knowledge-mcp] Error handling ${req.method}: ${err}\n`);
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
process.stderr.write(`[knowledge-mcp] Failed to parse JSON: ${trimmed.slice(0, 100)}\n`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
process.stdin.on('end', () => {
|
|
231
|
+
process.stderr.write('[knowledge-mcp] stdin closed, shutting down\n');
|
|
232
|
+
process.exit(0);
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
// ===== Main =====
|
|
236
|
+
const config = parseArgs();
|
|
237
|
+
startServer(config);
|
|
238
|
+
export {};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { AgentConfig } from '../types/index.js';
|
|
2
|
+
/**
|
|
3
|
+
* Generate MCP server config file for Claude Code.
|
|
4
|
+
* This file is auto-detected by runClaude and passed via --mcp-config.
|
|
5
|
+
*/
|
|
6
|
+
export declare function setupMcpConfig(config: AgentConfig): string;
|
|
7
|
+
/**
|
|
8
|
+
* Get the MCP config path if it exists.
|
|
9
|
+
*/
|
|
10
|
+
export declare function getMcpConfigPath(): string | undefined;
|
|
11
|
+
/**
|
|
12
|
+
* Write MCP server config into agent's folder .mcp.json.
|
|
13
|
+
* Claude Code reads .mcp.json automatically for project-level MCPs.
|
|
14
|
+
* Note: .claude/settings.json mcpServers is NOT reliably read by Claude Code CLI.
|
|
15
|
+
*/
|
|
16
|
+
export declare function writeAgentFolderMcpConfig(agentFolderPath: string, config: AgentConfig): void;
|
|
17
|
+
/**
|
|
18
|
+
* Clean up MCP config file.
|
|
19
|
+
*/
|
|
20
|
+
export declare function cleanupMcpConfig(): void;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
5
|
+
const __dirname = path.dirname(__filename);
|
|
6
|
+
const MCP_CONFIG_DIR = path.join(process.env.HOME || '', '.tuna-agent');
|
|
7
|
+
const MCP_CONFIG_PATH = path.join(MCP_CONFIG_DIR, 'mcp-config.json');
|
|
8
|
+
/**
|
|
9
|
+
* Generate MCP server config file for Claude Code.
|
|
10
|
+
* This file is auto-detected by runClaude and passed via --mcp-config.
|
|
11
|
+
*/
|
|
12
|
+
export function setupMcpConfig(config) {
|
|
13
|
+
const knowledgeServerPath = path.join(__dirname, 'knowledge-server.js');
|
|
14
|
+
const mcpConfig = {
|
|
15
|
+
mcpServers: {
|
|
16
|
+
'tuna-knowledge': {
|
|
17
|
+
command: process.execPath, // full path to current node binary
|
|
18
|
+
args: [
|
|
19
|
+
knowledgeServerPath,
|
|
20
|
+
'--api-url', config.apiUrl,
|
|
21
|
+
'--token', config.agentToken,
|
|
22
|
+
'--agent-id', config.agentId,
|
|
23
|
+
],
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
if (!fs.existsSync(MCP_CONFIG_DIR)) {
|
|
28
|
+
fs.mkdirSync(MCP_CONFIG_DIR, { recursive: true });
|
|
29
|
+
}
|
|
30
|
+
fs.writeFileSync(MCP_CONFIG_PATH, JSON.stringify(mcpConfig, null, 2));
|
|
31
|
+
console.log(`[MCP] Config written to ${MCP_CONFIG_PATH}`);
|
|
32
|
+
return MCP_CONFIG_PATH;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Get the MCP config path if it exists.
|
|
36
|
+
*/
|
|
37
|
+
export function getMcpConfigPath() {
|
|
38
|
+
if (fs.existsSync(MCP_CONFIG_PATH)) {
|
|
39
|
+
return MCP_CONFIG_PATH;
|
|
40
|
+
}
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Write MCP server config into agent's folder .mcp.json.
|
|
45
|
+
* Claude Code reads .mcp.json automatically for project-level MCPs.
|
|
46
|
+
* Note: .claude/settings.json mcpServers is NOT reliably read by Claude Code CLI.
|
|
47
|
+
*/
|
|
48
|
+
export function writeAgentFolderMcpConfig(agentFolderPath, config) {
|
|
49
|
+
const knowledgeServerPath = path.join(__dirname, 'knowledge-server.js');
|
|
50
|
+
try {
|
|
51
|
+
const mcpJsonPath = path.join(agentFolderPath, '.mcp.json');
|
|
52
|
+
// Read existing .mcp.json to preserve other servers (e.g. playwright)
|
|
53
|
+
let existing = {};
|
|
54
|
+
if (fs.existsSync(mcpJsonPath)) {
|
|
55
|
+
existing = JSON.parse(fs.readFileSync(mcpJsonPath, 'utf-8'));
|
|
56
|
+
}
|
|
57
|
+
const existingServers = existing.mcpServers ?? {};
|
|
58
|
+
existing.mcpServers = {
|
|
59
|
+
...existingServers,
|
|
60
|
+
'tuna-knowledge': {
|
|
61
|
+
command: process.execPath,
|
|
62
|
+
args: [
|
|
63
|
+
knowledgeServerPath,
|
|
64
|
+
'--api-url', config.apiUrl,
|
|
65
|
+
'--token', config.agentToken,
|
|
66
|
+
'--agent-id', config.agentId,
|
|
67
|
+
],
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
fs.writeFileSync(mcpJsonPath, JSON.stringify(existing, null, 2));
|
|
71
|
+
console.log(`[MCP] Agent folder .mcp.json written to ${mcpJsonPath}`);
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
console.warn(`[MCP] Failed to write agent folder .mcp.json:`, err);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Clean up MCP config file.
|
|
79
|
+
*/
|
|
80
|
+
export function cleanupMcpConfig() {
|
|
81
|
+
if (fs.existsSync(MCP_CONFIG_PATH)) {
|
|
82
|
+
fs.unlinkSync(MCP_CONFIG_PATH);
|
|
83
|
+
}
|
|
84
|
+
}
|
package/dist/types/index.d.ts
CHANGED
|
@@ -10,6 +10,7 @@ export interface AgentConfig {
|
|
|
10
10
|
agentConfig?: {
|
|
11
11
|
openclawGatewayUrl?: string;
|
|
12
12
|
};
|
|
13
|
+
extensionCodes?: string[];
|
|
13
14
|
}
|
|
14
15
|
export interface AgentCapabilities {
|
|
15
16
|
supports_teams: boolean;
|
|
@@ -41,6 +42,7 @@ export interface TaskAssignment {
|
|
|
41
42
|
confirmBeforeEdit?: boolean;
|
|
42
43
|
attachments?: ChatAttachment[];
|
|
43
44
|
source?: 'manual' | 'skill' | 'scheduled' | 'workflow' | 'api';
|
|
45
|
+
agentId?: string;
|
|
44
46
|
}
|
|
45
47
|
export interface ConnectResponse {
|
|
46
48
|
machine_id: string;
|
|
@@ -20,6 +20,8 @@ export interface ClaudeCliOptions {
|
|
|
20
20
|
onPermissionRequest?: (tool: string, detail: string) => Promise<boolean>;
|
|
21
21
|
/** Local file paths to pass as input (e.g., images) via --input-file flags */
|
|
22
22
|
inputFiles?: string[];
|
|
23
|
+
/** Path to MCP server config JSON file (passed via --mcp-config) */
|
|
24
|
+
mcpConfigPath?: string;
|
|
23
25
|
}
|
|
24
26
|
export interface ClaudeCliResult {
|
|
25
27
|
result: string;
|
package/dist/utils/claude-cli.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { spawn, execSync } from 'child_process';
|
|
2
2
|
import { StringDecoder } from 'string_decoder';
|
|
3
|
+
import { getMcpConfigPath } from '../mcp/setup.js';
|
|
3
4
|
/** Cached absolute path to `claude` binary, resolved once at first use */
|
|
4
5
|
let _claudeBinPath = null;
|
|
5
6
|
function getClaudeBinPath() {
|
|
@@ -101,6 +102,11 @@ export function runClaude(options) {
|
|
|
101
102
|
args.push('--tools', ''); // skip built-in tools
|
|
102
103
|
args.push('--disable-slash-commands'); // skip skills/plugins
|
|
103
104
|
}
|
|
105
|
+
// Auto-detect MCP config for knowledge server (skip if lightweight mode)
|
|
106
|
+
const mcpConfig = options.mcpConfigPath || (!options.lightweight ? getMcpConfigPath() : undefined);
|
|
107
|
+
if (mcpConfig) {
|
|
108
|
+
args.push('--mcp-config', mcpConfig);
|
|
109
|
+
}
|
|
104
110
|
if (options.permissionMode) {
|
|
105
111
|
args.push('--permission-mode', options.permissionMode);
|
|
106
112
|
}
|
|
@@ -111,6 +117,7 @@ export function runClaude(options) {
|
|
|
111
117
|
}
|
|
112
118
|
}
|
|
113
119
|
const useInteractiveStdin = !!options.permissionMode && !!options.onPermissionRequest;
|
|
120
|
+
console.log(`[claude-cli] Spawning with args: ${args.filter(a => !a.startsWith('sk-') && a.length < 200).join(' ')}`);
|
|
114
121
|
const claudeBin = getClaudeBinPath();
|
|
115
122
|
// Ensure PATH includes common bin dirs so shebang `#!/usr/bin/env node` resolves
|
|
116
123
|
const spawnPath = [
|
|
@@ -198,6 +205,10 @@ export function runClaude(options) {
|
|
|
198
205
|
proc.stderr.on('data', (chunk) => {
|
|
199
206
|
const text = stderrDecoder.write(chunk);
|
|
200
207
|
stderr += text;
|
|
208
|
+
// Log stderr for debugging (MCP server startup errors, etc.)
|
|
209
|
+
if (text.trim()) {
|
|
210
|
+
console.log(`[claude-cli] stderr: ${text.trim().substring(0, 300)}`);
|
|
211
|
+
}
|
|
201
212
|
// Detect permission prompts from Claude Code on stderr
|
|
202
213
|
if (useInteractiveStdin) {
|
|
203
214
|
handlePermissionPrompt(text, proc, options.onPermissionRequest);
|
|
@@ -29,10 +29,13 @@ export declare const CommandMessageSchema: z.ZodObject<{
|
|
|
29
29
|
type: z.ZodLiteral<"command">;
|
|
30
30
|
command: z.ZodString;
|
|
31
31
|
workspace_path: z.ZodOptional<z.ZodString>;
|
|
32
|
+
agent_folders: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
33
|
+
agent_id: z.ZodOptional<z.ZodString>;
|
|
34
|
+
folder_path: z.ZodOptional<z.ZodString>;
|
|
32
35
|
skill_id: z.ZodOptional<z.ZodString>;
|
|
33
36
|
source_path: z.ZodOptional<z.ZodString>;
|
|
34
37
|
skill_name: z.ZodOptional<z.ZodString>;
|
|
35
|
-
}, z.core.$
|
|
38
|
+
}, z.core.$loose>;
|
|
36
39
|
export declare const TaskCancelledSchema: z.ZodObject<{
|
|
37
40
|
type: z.ZodLiteral<"task_cancelled">;
|
|
38
41
|
taskId: z.ZodString;
|
|
@@ -21,11 +21,16 @@ export const CommandMessageSchema = z.object({
|
|
|
21
21
|
type: z.literal('command'),
|
|
22
22
|
command: z.string().min(1),
|
|
23
23
|
workspace_path: z.string().optional(),
|
|
24
|
+
// For scan_workspace command
|
|
25
|
+
agent_folders: z.array(z.string()).optional(),
|
|
26
|
+
// For rescan_agent_skills command
|
|
27
|
+
agent_id: z.string().optional(),
|
|
28
|
+
folder_path: z.string().optional(),
|
|
24
29
|
// For analyze_skill command
|
|
25
30
|
skill_id: z.string().optional(),
|
|
26
31
|
source_path: z.string().optional(),
|
|
27
32
|
skill_name: z.string().optional(),
|
|
28
|
-
});
|
|
33
|
+
}).passthrough();
|
|
29
34
|
export const TaskCancelledSchema = z.object({
|
|
30
35
|
type: z.literal('task_cancelled'),
|
|
31
36
|
taskId: z.string().min(1),
|