fluxy-bot 0.8.9 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/README.md +53 -84
  2. package/bin/cli.js +1717 -6
  3. package/components.json +18 -18
  4. package/package.json +98 -109
  5. package/postcss.config.js +3 -3
  6. package/scripts/install.ps1 +1 -1
  7. package/scripts/install.sh +1 -1
  8. package/scripts/postinstall.js +47 -58
  9. package/shared/ai.ts +118 -202
  10. package/shared/config.ts +37 -39
  11. package/shared/logger.ts +6 -14
  12. package/shared/paths.ts +10 -16
  13. package/shared/relay.ts +89 -98
  14. package/supervisor/backend.ts +77 -89
  15. package/supervisor/chat/ARCHITECTURE.md +13 -14
  16. package/supervisor/chat/OnboardWizard.tsx +2194 -3419
  17. package/supervisor/chat/fluxy-main.tsx +495 -633
  18. package/supervisor/chat/fluxy.html +24 -36
  19. package/supervisor/chat/onboard-main.tsx +8 -8
  20. package/supervisor/chat/onboard.html +10 -13
  21. package/supervisor/chat/src/components/Chat/AudioBubble.tsx +94 -108
  22. package/supervisor/chat/src/components/Chat/ChatView.tsx +14 -36
  23. package/supervisor/chat/src/components/Chat/ImageLightbox.tsx +71 -89
  24. package/supervisor/chat/src/components/Chat/InputBar.tsx +498 -728
  25. package/supervisor/chat/src/components/Chat/MessageBubble.tsx +154 -246
  26. package/supervisor/chat/src/components/Chat/MessageList.tsx +114 -138
  27. package/supervisor/chat/src/components/Chat/TypingIndicator.tsx +38 -50
  28. package/supervisor/chat/src/components/LoginScreen.tsx +245 -296
  29. package/supervisor/chat/src/hooks/useChat.ts +210 -259
  30. package/supervisor/chat/src/hooks/useFluxyChat.ts +232 -322
  31. package/supervisor/chat/src/hooks/useSpeechRecognition.ts +126 -148
  32. package/supervisor/chat/src/lib/auth.ts +20 -23
  33. package/supervisor/chat/src/lib/ws-client.ts +113 -125
  34. package/supervisor/chat/src/styles/globals.css +76 -85
  35. package/supervisor/file-saver.ts +39 -52
  36. package/supervisor/fluxy-agent.ts +213 -292
  37. package/supervisor/index.ts +895 -1263
  38. package/supervisor/scheduler.ts +237 -299
  39. package/supervisor/tunnel.ts +113 -167
  40. package/supervisor/vite-dev.ts +51 -61
  41. package/supervisor/widget.js +135 -147
  42. package/supervisor/worker.ts +45 -49
  43. package/tsconfig.json +18 -24
  44. package/vite.config.ts +48 -55
  45. package/vite.fluxy.config.ts +34 -34
  46. package/worker/claude-auth.ts +202 -265
  47. package/worker/codex-auth.ts +144 -181
  48. package/worker/db.ts +63 -191
  49. package/worker/index.ts +601 -842
  50. package/workspace/MYSELF.md +1 -1
  51. package/workspace/PULSE.json +6 -6
  52. package/workspace/backend/index.ts +16 -19
  53. package/workspace/client/index.html +47 -60
  54. package/workspace/client/public/manifest.json +21 -21
  55. package/workspace/client/public/sw.js +29 -31
  56. package/workspace/client/src/App.tsx +91 -136
  57. package/workspace/client/src/components/Dashboard/DashboardPage.tsx +68 -72
  58. package/workspace/client/src/components/ErrorBoundary.tsx +11 -11
  59. package/workspace/client/src/components/Layout/DashboardLayout.tsx +35 -35
  60. package/workspace/client/src/components/Layout/Footer.tsx +12 -12
  61. package/workspace/client/src/components/Layout/MobileNav.tsx +23 -23
  62. package/workspace/client/src/components/Layout/Sidebar.tsx +81 -81
  63. package/workspace/client/src/components/ui/avatar.tsx +85 -85
  64. package/workspace/client/src/components/ui/badge.tsx +40 -41
  65. package/workspace/client/src/components/ui/button.tsx +56 -57
  66. package/workspace/client/src/components/ui/card.tsx +75 -75
  67. package/workspace/client/src/components/ui/dialog.tsx +114 -117
  68. package/workspace/client/src/components/ui/dropdown-menu.tsx +191 -199
  69. package/workspace/client/src/components/ui/input.tsx +17 -17
  70. package/workspace/client/src/components/ui/scroll-area.tsx +47 -47
  71. package/workspace/client/src/components/ui/select.tsx +145 -151
  72. package/workspace/client/src/components/ui/separator.tsx +21 -21
  73. package/workspace/client/src/components/ui/sheet.tsx +102 -102
  74. package/workspace/client/src/components/ui/skeleton.tsx +10 -10
  75. package/workspace/client/src/components/ui/switch.tsx +26 -26
  76. package/workspace/client/src/components/ui/tabs.tsx +69 -69
  77. package/workspace/client/src/components/ui/textarea.tsx +14 -14
  78. package/workspace/client/src/components/ui/tooltip.tsx +37 -37
  79. package/workspace/client/src/lib/utils.ts +1 -1
  80. package/workspace/client/src/main.tsx +3 -3
  81. package/workspace/client/src/styles/globals.css +76 -85
  82. package/workspace/skills/code-reviewer/.claude-plugin/plugin.json +3 -3
  83. package/workspace/skills/code-reviewer/skills/code-reviewer/SKILL.md +0 -6
  84. package/workspace/skills/daily-standup/.claude-plugin/plugin.json +3 -3
  85. package/workspace/skills/daily-standup/skills/daily-standup/SKILL.md +0 -7
  86. package/workspace/skills/workspace-helper/.claude-plugin/plugin.json +3 -3
  87. package/workspace/skills/workspace-helper/skills/workspace-helper/SKILL.md +0 -2
  88. package/bin/cli.backup.js +0 -2138
  89. package/cli/commands/daemon.ts +0 -42
  90. package/cli/commands/init.ts +0 -32
  91. package/cli/commands/start.ts +0 -113
  92. package/cli/commands/tunnel.temp.ts +0 -206
  93. package/cli/commands/tunnel.ts +0 -227
  94. package/cli/commands/update.ts +0 -163
  95. package/cli/core/base-adapter.ts +0 -156
  96. package/cli/core/cloudflared.ts +0 -113
  97. package/cli/core/config.ts +0 -73
  98. package/cli/core/os-detector.ts +0 -43
  99. package/cli/core/server.ts +0 -109
  100. package/cli/core/types.ts +0 -15
  101. package/cli/index.ts +0 -72
  102. package/cli/platforms/darwin.ts +0 -146
  103. package/cli/platforms/index.ts +0 -21
  104. package/cli/platforms/linux.ts +0 -156
  105. package/cli/platforms/win32.ts +0 -34
  106. package/cli/utils/ui.ts +0 -37
@@ -3,11 +3,7 @@
3
3
  * Fresh context per turn — memory files and conversation history are injected into the system prompt.
4
4
  */
5
5
 
6
- import {
7
- query,
8
- type SDKMessage,
9
- type SDKUserMessage
10
- } from '@anthropic-ai/claude-agent-sdk';
6
+ import { query, type SDKMessage, type SDKUserMessage } from '@anthropic-ai/claude-agent-sdk';
11
7
  import fs from 'fs';
12
8
  import path from 'path';
13
9
  import { log } from '../shared/logger.js';
@@ -15,128 +11,102 @@ import { PKG_DIR, WORKSPACE_DIR } from '../shared/paths.js';
15
11
  import type { SavedFile } from './file-saver.js';
16
12
  import { getClaudeAccessToken } from '../worker/claude-auth.js';
17
13
 
18
- const PROMPT_FILE = path.join(
19
- import.meta.dirname,
20
- '..',
21
- 'worker',
22
- 'prompts',
23
- 'fluxy-system-prompt.txt'
24
- );
14
+ const PROMPT_FILE = path.join(import.meta.dirname, '..', 'worker', 'prompts', 'fluxy-system-prompt.txt');
25
15
 
26
16
  export interface RecentMessage {
27
- role: 'user' | 'assistant';
28
- content: string;
17
+ role: 'user' | 'assistant';
18
+ content: string;
29
19
  }
30
20
 
31
21
  interface ActiveQuery {
32
- abortController: AbortController;
22
+ abortController: AbortController;
33
23
  }
34
24
 
35
25
  const activeQueries = new Map<string, ActiveQuery>();
36
26
 
37
27
  export interface AgentAttachment {
38
- type: 'image' | 'file';
39
- name: string;
40
- mediaType: string;
41
- data: string; // base64
28
+ type: 'image' | 'file';
29
+ name: string;
30
+ mediaType: string;
31
+ data: string; // base64
42
32
  }
43
33
 
44
34
  /** Read a memory file from workspace, returning '(empty)' if missing or empty */
45
35
  function readMemoryFile(filename: string): string {
46
- try {
47
- const content = fs
48
- .readFileSync(path.join(WORKSPACE_DIR, filename), 'utf-8')
49
- .trim();
50
- return content || '(empty)';
51
- } catch {
52
- return '(empty)';
53
- }
36
+ try {
37
+ const content = fs.readFileSync(path.join(WORKSPACE_DIR, filename), 'utf-8').trim();
38
+ return content || '(empty)';
39
+ } catch {
40
+ return '(empty)';
41
+ }
54
42
  }
55
43
 
56
44
  /** Read all memory + config files and return their contents */
57
- function readMemoryFiles(): {
58
- myself: string;
59
- myhuman: string;
60
- memory: string;
61
- pulse: string;
62
- crons: string;
63
- } {
64
- return {
65
- myself: readMemoryFile('MYSELF.md'),
66
- myhuman: readMemoryFile('MYHUMAN.md'),
67
- memory: readMemoryFile('MEMORY.md'),
68
- pulse: readMemoryFile('PULSE.json'),
69
- crons: readMemoryFile('CRONS.json')
70
- };
45
+ function readMemoryFiles(): { myself: string; myhuman: string; memory: string; pulse: string; crons: string } {
46
+ return {
47
+ myself: readMemoryFile('MYSELF.md'),
48
+ myhuman: readMemoryFile('MYHUMAN.md'),
49
+ memory: readMemoryFile('MEMORY.md'),
50
+ pulse: readMemoryFile('PULSE.json'),
51
+ crons: readMemoryFile('CRONS.json'),
52
+ };
71
53
  }
72
54
 
73
55
  /** Format recent messages as conversation history text */
74
56
  function formatConversationHistory(messages: RecentMessage[]): string {
75
- if (!messages.length) return '';
76
- return messages.map(m => `${m.role}: ${m.content}`).join('\n\n');
57
+ if (!messages.length) return '';
58
+ return messages.map((m) => `${m.role}: ${m.content}`).join('\n\n');
77
59
  }
78
60
 
79
61
  /** Build a multi-part prompt with attachments for the SDK */
80
- function buildMultiPartPrompt(
81
- text: string,
82
- attachments: AgentAttachment[],
83
- savedFiles?: SavedFile[]
84
- ): AsyncIterable<SDKUserMessage> {
85
- return (async function* () {
86
- const content: any[] = [];
87
-
88
- for (const att of attachments) {
89
- if (att.type === 'image') {
90
- content.push({
91
- type: 'image',
92
- source: {
93
- type: 'base64',
94
- media_type: att.mediaType,
95
- data: att.data
96
- }
97
- });
98
- } else {
99
- content.push({
100
- type: 'document',
101
- source: {
102
- type: 'base64',
103
- media_type: att.mediaType,
104
- data: att.data
105
- }
106
- });
107
- }
108
- }
62
+ function buildMultiPartPrompt(text: string, attachments: AgentAttachment[], savedFiles?: SavedFile[]): AsyncIterable<SDKUserMessage> {
63
+ return (async function* () {
64
+ const content: any[] = [];
65
+
66
+ for (const att of attachments) {
67
+ if (att.type === 'image') {
68
+ content.push({
69
+ type: 'image',
70
+ source: { type: 'base64', media_type: att.mediaType, data: att.data },
71
+ });
72
+ } else {
73
+ content.push({
74
+ type: 'document',
75
+ source: { type: 'base64', media_type: att.mediaType, data: att.data },
76
+ });
77
+ }
78
+ }
109
79
 
110
- // Append local file paths so Claude can reference them with tools
111
- let promptText = text || '(attached files)';
112
- if (savedFiles?.length) {
113
- const lines = savedFiles.map(f => `- ${f.name} -> ${f.relPath}`);
114
- promptText += `\n\n[Attached files saved to disk]\n${lines.join('\n')}\nYou can read or reference these files using the paths above (relative to your cwd).`;
115
- }
80
+ // Append local file paths so Claude can reference them with tools
81
+ let promptText = text || '(attached files)';
82
+ if (savedFiles?.length) {
83
+ const lines = savedFiles.map((f) => `- ${f.name} -> ${f.relPath}`);
84
+ promptText += `\n\n[Attached files saved to disk]\n${lines.join('\n')}\nYou can read or reference these files using the paths above (relative to your cwd).`;
85
+ }
116
86
 
117
- content.push({ type: 'text', text: promptText });
87
+ content.push({ type: 'text', text: promptText });
118
88
 
119
- yield {
120
- type: 'user' as const,
121
- message: { role: 'user' as const, content },
122
- parent_tool_use_id: null
123
- } as SDKUserMessage;
124
- })();
89
+ yield {
90
+ type: 'user' as const,
91
+ message: { role: 'user' as const, content },
92
+ parent_tool_use_id: null,
93
+ } as SDKUserMessage;
94
+ })();
125
95
  }
126
96
 
127
97
  /** Read the custom system prompt, replacing $BOT and $HUMAN placeholders */
128
98
  function readSystemPrompt(botName = 'Fluxy', humanName = 'Human'): string {
129
- try {
130
- const raw = fs.readFileSync(PROMPT_FILE, 'utf-8').trim();
131
- if (!raw) {
132
- log.warn('System prompt file is empty — using minimal fallback');
133
- return `You are ${botName}, a helpful AI agent. Your human is ${humanName}.`;
134
- }
135
- return raw.replace(/\$BOT/g, botName).replace(/\$HUMAN/g, humanName);
136
- } catch {
137
- log.warn('System prompt file not found — using minimal fallback');
138
- return `You are ${botName}, a helpful AI agent. Your human is ${humanName}.`;
99
+ try {
100
+ const raw = fs.readFileSync(PROMPT_FILE, 'utf-8').trim();
101
+ if (!raw) {
102
+ log.warn('System prompt file is empty — using minimal fallback');
103
+ return `You are ${botName}, a helpful AI agent. Your human is ${humanName}.`;
139
104
  }
105
+ return raw.replace(/\$BOT/g, botName).replace(/\$HUMAN/g, humanName);
106
+ } catch {
107
+ log.warn('System prompt file not found — using minimal fallback');
108
+ return `You are ${botName}, a helpful AI agent. Your human is ${humanName}.`;
109
+ }
140
110
  }
141
111
 
142
112
  /**
@@ -144,223 +114,174 @@ function readSystemPrompt(botName = 'Fluxy', humanName = 'Human'): string {
144
114
  * Fresh context each turn — memory files and history are injected into the system prompt.
145
115
  */
146
116
  export async function startFluxyAgentQuery(
147
- conversationId: string,
148
- prompt: string,
149
- model: string,
150
- onMessage: (type: string, data: any) => void,
151
- attachments?: AgentAttachment[],
152
- savedFiles?: SavedFile[],
153
- names?: { botName: string; humanName: string },
154
- recentMessages?: RecentMessage[]
117
+ conversationId: string,
118
+ prompt: string,
119
+ model: string,
120
+ onMessage: (type: string, data: any) => void,
121
+ attachments?: AgentAttachment[],
122
+ savedFiles?: SavedFile[],
123
+ names?: { botName: string; humanName: string },
124
+ recentMessages?: RecentMessage[],
155
125
  ): Promise<void> {
156
- const oauthToken = await getClaudeAccessToken();
157
- if (!oauthToken) {
158
- onMessage('bot:error', {
159
- conversationId,
160
- error: 'Claude OAuth token not found. Please authenticate via the dashboard.'
161
- });
162
- return;
163
- }
126
+ const oauthToken = await getClaudeAccessToken();
127
+ if (!oauthToken) {
128
+ onMessage('bot:error', { conversationId, error: 'Claude OAuth token not found. Please authenticate via the dashboard.' });
129
+ return;
130
+ }
164
131
 
165
- const abortController = new AbortController();
166
- const basePrompt = readSystemPrompt(names?.botName, names?.humanName);
167
- const memoryFiles = readMemoryFiles();
132
+ const abortController = new AbortController();
133
+ const basePrompt = readSystemPrompt(names?.botName, names?.humanName);
134
+ const memoryFiles = readMemoryFiles();
168
135
 
169
- // Build enriched system prompt with memory files and conversation history
170
- let enrichedPrompt = basePrompt;
136
+ // Build enriched system prompt with memory files and conversation history
137
+ let enrichedPrompt = basePrompt;
171
138
 
172
- enrichedPrompt += `\n\n---\n# Your Memory Files\n\n## MYSELF.md\n${memoryFiles.myself}\n\n## MYHUMAN.md\n${memoryFiles.myhuman}\n\n## MEMORY.md\n${memoryFiles.memory}\n\n---\n# Your Config Files\n\n## PULSE.json\n${memoryFiles.pulse}\n\n## CRONS.json\n${memoryFiles.crons}`;
139
+ enrichedPrompt += `\n\n---\n# Your Memory Files\n\n## MYSELF.md\n${memoryFiles.myself}\n\n## MYHUMAN.md\n${memoryFiles.myhuman}\n\n## MEMORY.md\n${memoryFiles.memory}\n\n---\n# Your Config Files\n\n## PULSE.json\n${memoryFiles.pulse}\n\n## CRONS.json\n${memoryFiles.crons}`;
173
140
 
174
- if (recentMessages?.length) {
175
- enrichedPrompt += `\n\n---\n# Recent Conversation\n${formatConversationHistory(recentMessages)}`;
176
- }
141
+ if (recentMessages?.length) {
142
+ enrichedPrompt += `\n\n---\n# Recent Conversation\n${formatConversationHistory(recentMessages)}`;
143
+ }
177
144
 
178
- activeQueries.set(conversationId, { abortController });
145
+ activeQueries.set(conversationId, { abortController });
179
146
 
180
- let fullText = '';
181
- const usedTools = new Set<string>();
182
- let stderrBuf = '';
147
+ let fullText = '';
148
+ const usedTools = new Set<string>();
149
+ let stderrBuf = '';
183
150
 
184
- // If there are saved files but no inline attachments, append path info to plain text prompt
185
- let plainPrompt = prompt;
186
- if (savedFiles?.length && !attachments?.length) {
187
- const lines = savedFiles.map(f => `- ${f.name} -> ${f.relPath}`);
188
- plainPrompt += `\n\n[Attached files saved to disk]\n${lines.join('\n')}\nYou can read or reference these files using the paths above (relative to your cwd).`;
189
- }
151
+ // If there are saved files but no inline attachments, append path info to plain text prompt
152
+ let plainPrompt = prompt;
153
+ if (savedFiles?.length && !attachments?.length) {
154
+ const lines = savedFiles.map((f) => `- ${f.name} -> ${f.relPath}`);
155
+ plainPrompt += `\n\n[Attached files saved to disk]\n${lines.join('\n')}\nYou can read or reference these files using the paths above (relative to your cwd).`;
156
+ }
190
157
 
191
- const sdkPrompt: string | AsyncIterable<SDKUserMessage> =
192
- attachments?.length
193
- ? buildMultiPartPrompt(prompt, attachments, savedFiles)
194
- : plainPrompt;
158
+ const sdkPrompt: string | AsyncIterable<SDKUserMessage> =
159
+ attachments?.length ? buildMultiPartPrompt(prompt, attachments, savedFiles) : plainPrompt;
195
160
 
161
+ try {
162
+ // Auto-discover all skill plugins in workspace/skills/ — any folder with a valid plugin.json is loaded
163
+ const skillsDir = path.join(PKG_DIR, 'workspace', 'skills');
164
+ const plugins: { type: 'local'; path: string }[] = [];
196
165
  try {
197
- // Auto-discover all skill plugins in workspace/skills/ any folder with a valid plugin.json is loaded
198
- const skillsDir = path.join(WORKSPACE_DIR, 'skills');
199
- const plugins: { type: 'local'; path: string }[] = [];
200
- try {
201
- for (const entry of fs.readdirSync(skillsDir, {
202
- withFileTypes: true
203
- })) {
204
- if (
205
- entry.isDirectory() &&
206
- fs.existsSync(
207
- path.join(
208
- skillsDir,
209
- entry.name,
210
- '.claude-plugin',
211
- 'plugin.json'
212
- )
213
- )
214
- ) {
215
- plugins.push({
216
- type: 'local' as const,
217
- path: path.join(skillsDir, entry.name)
218
- });
219
- }
220
- }
221
- } catch {}
222
-
223
- // Load MCP server config from workspace/MCP.json if it exists
224
- // Format: { "server-name": { command, args, env }, ... } (object, not array)
225
- let mcpServers: Record<string, any> | undefined;
226
- try {
227
- const mcpConfigPath = path.join(WORKSPACE_DIR, 'MCP.json');
228
- const mcpConfig = JSON.parse(
229
- fs.readFileSync(mcpConfigPath, 'utf-8')
230
- );
231
- if (
232
- mcpConfig &&
233
- typeof mcpConfig === 'object' &&
234
- !Array.isArray(mcpConfig) &&
235
- Object.keys(mcpConfig).length
236
- ) {
237
- // Already in correct format: { "name": { command, args, env } }
238
- mcpServers = mcpConfig;
239
- } else if (Array.isArray(mcpConfig) && mcpConfig.length) {
240
- // Legacy array format: merge all entries into a single object
241
- mcpServers = Object.assign({}, ...mcpConfig);
242
- }
243
- if (mcpServers) {
244
- const names = Object.keys(mcpServers).join(', ');
245
- log.info(`Loaded MCP server(s): [${names}]`);
246
- }
247
- } catch {}
248
-
249
- const claudeQuery = query({
250
- prompt: sdkPrompt,
251
- options: {
252
- model,
253
- cwd: WORKSPACE_DIR,
254
- permissionMode: 'bypassPermissions',
255
- allowDangerouslySkipPermissions: true,
256
- maxTurns: 50,
257
- abortController,
258
- systemPrompt: enrichedPrompt,
259
- plugins: plugins.length ? plugins : undefined,
260
- mcpServers,
261
- stderr: (chunk: string) => {
262
- stderrBuf += chunk;
263
- },
264
- env: {
265
- ...(process.env as Record<string, string>),
266
- CLAUDE_CODE_OAUTH_TOKEN: oauthToken,
267
- CLAUDE_CODE_BUBBLEWRAP: '1'
268
- }
269
- }
270
- });
166
+ for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) {
167
+ if (entry.isDirectory() && fs.existsSync(path.join(skillsDir, entry.name, '.claude-plugin', 'plugin.json'))) {
168
+ plugins.push({ type: 'local' as const, path: path.join(skillsDir, entry.name) });
169
+ }
170
+ }
171
+ } catch {}
271
172
 
272
- onMessage('bot:typing', { conversationId });
273
-
274
- for await (const msg of claudeQuery) {
275
- if (abortController.signal.aborted) break;
276
-
277
- switch (msg.type) {
278
- case 'assistant': {
279
- const assistantMsg = msg.message;
280
- if (!assistantMsg?.content) break;
281
-
282
- for (const block of assistantMsg.content) {
283
- if (block.type === 'text' && block.text) {
284
- // Add separator between text from different assistant turns
285
- if (fullText && !fullText.endsWith('\n')) {
286
- fullText += '\n\n';
287
- onMessage('bot:token', {
288
- conversationId,
289
- token: '\n\n'
290
- });
291
- }
292
- fullText += block.text;
293
- onMessage('bot:token', {
294
- conversationId,
295
- token: block.text
296
- });
297
- } else if (block.type === 'tool_use') {
298
- usedTools.add(block.name);
299
- onMessage('bot:tool', {
300
- conversationId,
301
- name: block.name,
302
- input: block.input
303
- });
304
- }
305
- }
306
- break;
307
- }
308
-
309
- case 'result': {
310
- if (fullText) {
311
- onMessage('bot:response', {
312
- conversationId,
313
- content: fullText
314
- });
315
- fullText = ''; // prevent duplicate
316
- } else if (msg.subtype?.startsWith('error')) {
317
- const errorText =
318
- (msg as any).errors?.join('; ') ||
319
- 'Agent query failed';
320
- onMessage('bot:error', {
321
- conversationId,
322
- error: errorText
323
- });
324
- }
325
- break;
326
- }
327
-
328
- case 'tool_progress':
329
- onMessage('bot:tool', {
330
- conversationId,
331
- name: (msg as any).tool_name || 'working',
332
- status: 'running'
333
- });
334
- break;
173
+ // Load MCP server config from workspace/MCP.json if it exists
174
+ // Format: { "server-name": { command, args, env }, ... } (object, not array)
175
+ let mcpServers: Record<string, any> | undefined;
176
+ try {
177
+ const mcpConfigPath = path.join(WORKSPACE_DIR, 'MCP.json');
178
+ const mcpConfig = JSON.parse(fs.readFileSync(mcpConfigPath, 'utf-8'));
179
+ if (mcpConfig && typeof mcpConfig === 'object' && !Array.isArray(mcpConfig) && Object.keys(mcpConfig).length) {
180
+ // Already in correct format: { "name": { command, args, env } }
181
+ mcpServers = mcpConfig;
182
+ } else if (Array.isArray(mcpConfig) && mcpConfig.length) {
183
+ // Legacy array format: merge all entries into a single object
184
+ mcpServers = Object.assign({}, ...mcpConfig);
185
+ }
186
+ if (mcpServers) {
187
+ const names = Object.keys(mcpServers).join(', ');
188
+ log.info(`Loaded MCP server(s): [${names}]`);
189
+ }
190
+ } catch {}
191
+
192
+ const claudeQuery = query({
193
+ prompt: sdkPrompt,
194
+ options: {
195
+ model,
196
+ cwd: path.join(PKG_DIR, 'workspace'),
197
+ permissionMode: 'bypassPermissions',
198
+ allowDangerouslySkipPermissions: true,
199
+ maxTurns: 50,
200
+ abortController,
201
+ systemPrompt: enrichedPrompt,
202
+ plugins: plugins.length ? plugins : undefined,
203
+ mcpServers,
204
+ stderr: (chunk: string) => { stderrBuf += chunk; },
205
+ env: {
206
+ ...process.env as Record<string, string>,
207
+ CLAUDE_CODE_OAUTH_TOKEN: oauthToken,
208
+ CLAUDE_CODE_BUBBLEWRAP: '1',
209
+ },
210
+ },
211
+ });
212
+
213
+ onMessage('bot:typing', { conversationId });
214
+
215
+ for await (const msg of claudeQuery) {
216
+ if (abortController.signal.aborted) break;
217
+
218
+ switch (msg.type) {
219
+ case 'assistant': {
220
+ const assistantMsg = msg.message;
221
+ if (!assistantMsg?.content) break;
222
+
223
+ for (const block of assistantMsg.content) {
224
+ if (block.type === 'text' && block.text) {
225
+ // Add separator between text from different assistant turns
226
+ if (fullText && !fullText.endsWith('\n')) {
227
+ fullText += '\n\n';
228
+ onMessage('bot:token', { conversationId, token: '\n\n' });
229
+ }
230
+ fullText += block.text;
231
+ onMessage('bot:token', { conversationId, token: block.text });
232
+ } else if (block.type === 'tool_use') {
233
+ usedTools.add(block.name);
234
+ onMessage('bot:tool', { conversationId, name: block.name, input: block.input });
335
235
  }
236
+ }
237
+ break;
336
238
  }
337
239
 
338
- // If we accumulated text but didn't hit a result message, send what we have
339
- if (fullText && !abortController.signal.aborted) {
240
+ case 'result': {
241
+ if (fullText) {
340
242
  onMessage('bot:response', { conversationId, content: fullText });
243
+ fullText = ''; // prevent duplicate
244
+ } else if (msg.subtype?.startsWith('error')) {
245
+ const errorText = (msg as any).errors?.join('; ') || 'Agent query failed';
246
+ onMessage('bot:error', { conversationId, error: errorText });
247
+ }
248
+ break;
341
249
  }
342
- } catch (err: any) {
343
- if (!abortController.signal.aborted) {
344
- const detail = stderrBuf.trim();
345
- const msg = detail
346
- ? `${err.message}\n\nCLI stderr:\n${detail}`
347
- : err.message;
348
- log.warn(`Fluxy agent error (${conversationId}): ${msg}`);
349
- onMessage('bot:error', { conversationId, error: msg });
350
- }
351
- } finally {
352
- activeQueries.delete(conversationId);
353
- const FILE_TOOLS = ['Write', 'Edit'];
354
- const usedFileTools = FILE_TOOLS.some(t => usedTools.has(t));
355
- onMessage('bot:done', { conversationId, usedFileTools });
250
+
251
+ case 'tool_progress':
252
+ onMessage('bot:tool', {
253
+ conversationId,
254
+ name: (msg as any).tool_name || 'working',
255
+ status: 'running',
256
+ });
257
+ break;
258
+ }
356
259
  }
260
+
261
+ // If we accumulated text but didn't hit a result message, send what we have
262
+ if (fullText && !abortController.signal.aborted) {
263
+ onMessage('bot:response', { conversationId, content: fullText });
264
+ }
265
+ } catch (err: any) {
266
+ if (!abortController.signal.aborted) {
267
+ const detail = stderrBuf.trim();
268
+ const msg = detail ? `${err.message}\n\nCLI stderr:\n${detail}` : err.message;
269
+ log.warn(`Fluxy agent error (${conversationId}): ${msg}`);
270
+ onMessage('bot:error', { conversationId, error: msg });
271
+ }
272
+ } finally {
273
+ activeQueries.delete(conversationId);
274
+ const FILE_TOOLS = ['Write', 'Edit'];
275
+ const usedFileTools = FILE_TOOLS.some((t) => usedTools.has(t));
276
+ onMessage('bot:done', { conversationId, usedFileTools });
277
+ }
357
278
  }
358
279
 
359
280
  /** Stop an in-flight query */
360
281
  export function stopFluxyAgentQuery(conversationId: string): void {
361
- const q = activeQueries.get(conversationId);
362
- if (q) {
363
- q.abortController.abort();
364
- activeQueries.delete(conversationId);
365
- }
282
+ const q = activeQueries.get(conversationId);
283
+ if (q) {
284
+ q.abortController.abort();
285
+ activeQueries.delete(conversationId);
286
+ }
366
287
  }