@csmedeiros/codemax 1.0.3 → 1.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +4 -20
  2. package/launcher.js +28 -0
  3. package/package.json +15 -121
  4. package/LICENSE +0 -21
  5. package/dist/commands/cursorRules.d.ts +0 -15
  6. package/dist/commands/cursorRules.js +0 -118
  7. package/dist/commands/slashCommands.d.ts +0 -36
  8. package/dist/commands/slashCommands.js +0 -236
  9. package/dist/configuration/configManager.d.ts +0 -32
  10. package/dist/configuration/configManager.js +0 -71
  11. package/dist/configuration/modelContextWindows.d.ts +0 -3
  12. package/dist/configuration/modelContextWindows.js +0 -13
  13. package/dist/conversation/agentGraph.d.ts +0 -203
  14. package/dist/conversation/agentGraph.js +0 -433
  15. package/dist/conversation/agentTurn.d.ts +0 -40
  16. package/dist/conversation/agentTurn.js +0 -252
  17. package/dist/conversation/chatHistory.d.ts +0 -24
  18. package/dist/conversation/chatHistory.js +0 -251
  19. package/dist/conversation/compactionUtils.d.ts +0 -17
  20. package/dist/conversation/compactionUtils.js +0 -57
  21. package/dist/conversation/prompts/planPrompt.d.ts +0 -1
  22. package/dist/conversation/prompts/planPrompt.js +0 -12
  23. package/dist/conversation/prompts/systemPrompt.d.ts +0 -29
  24. package/dist/conversation/prompts/systemPrompt.js +0 -149
  25. package/dist/entry/cli.d.ts +0 -2
  26. package/dist/entry/cli.js +0 -24
  27. package/dist/observability/langfuseTracing.d.ts +0 -5
  28. package/dist/observability/langfuseTracing.js +0 -76
  29. package/dist/shared/types.d.ts +0 -25
  30. package/dist/shared/types.js +0 -1
  31. package/dist/terminal/app.d.ts +0 -2
  32. package/dist/terminal/app.js +0 -1236
  33. package/dist/terminal/components.d.ts +0 -18
  34. package/dist/terminal/components.js +0 -43
  35. package/dist/terminal/markdown.d.ts +0 -4
  36. package/dist/terminal/markdown.js +0 -47
  37. package/dist/terminal/screens/compactionSettings.d.ts +0 -6
  38. package/dist/terminal/screens/compactionSettings.js +0 -66
  39. package/dist/terminal/screens/modelSettings.d.ts +0 -10
  40. package/dist/terminal/screens/modelSettings.js +0 -76
  41. package/dist/terminal/textField.d.ts +0 -7
  42. package/dist/terminal/textField.js +0 -136
  43. package/dist/terminal/theme.d.ts +0 -23
  44. package/dist/terminal/theme.js +0 -23
  45. package/dist/tooling/mcpConfig.d.ts +0 -40
  46. package/dist/tooling/mcpConfig.js +0 -49
  47. package/dist/tooling/planControlChannel.d.ts +0 -2
  48. package/dist/tooling/planControlChannel.js +0 -21
  49. package/dist/tooling/toolConfig.d.ts +0 -42
  50. package/dist/tooling/toolConfig.js +0 -138
  51. package/dist/tooling/toolUiCallback.d.ts +0 -21
  52. package/dist/tooling/toolUiCallback.js +0 -268
  53. package/dist/tooling/tools.d.ts +0 -216
  54. package/dist/tooling/tools.js +0 -614
@@ -1,614 +0,0 @@
1
- /**
2
- * Tools for the LangChain agent.
3
- * MCP Playwright is optional: if the server is unavailable, the agent runs without browser tools.
4
- */
5
- import { spawn, execFile } from 'node:child_process';
6
- import http from 'node:http';
7
- import crypto from 'node:crypto';
8
- import os from 'node:os';
9
- import path from 'node:path';
10
- import fs from 'node:fs';
11
- import { MultiServerMCPClient } from '@langchain/mcp-adapters';
12
- import { loadMcpServers } from './mcpConfig.js';
13
- import { registerPlanNonce } from './planControlChannel.js';
14
- import { tool } from 'langchain';
15
- import { z } from 'zod';
16
- import { readFile, writeFile } from 'node:fs/promises';
17
- class AuthError extends Error {
18
- }
19
- const MCP_TIMEOUT_MS = Number(process.env['MCP_CONNECT_TIMEOUT_MS'] ?? '8000');
20
- const PLAYWRIGHT_MCP_URL = process.env['MCP_PLAYWRIGHT_URL'] ?? 'http://localhost:8931/mcp';
21
- const DEFAULT_SHELL_TIMEOUT_MS = Number(process.env['CODEMAX_SHELL_TIMEOUT_MS'] ?? '120000');
22
- const MAX_SHELL_OUTPUT_CHARS = Number(process.env['CODEMAX_SHELL_MAX_OUTPUT'] ?? '80000');
23
- const DEFAULT_SHELL_CWD = process.env['CODEMAX_SHELL_CWD'] ?? process.cwd();
24
- function truncateOutput(text, maxChars) {
25
- if (text.length <= maxChars)
26
- return text;
27
- const omitted = text.length - maxChars;
28
- return `${text.slice(0, maxChars)}\n\n… [output truncated: ${omitted} characters omitted]`;
29
- }
30
- function openInBrowser(filePath) {
31
- const platform = process.platform;
32
- if (platform === 'darwin') {
33
- execFile('open', [filePath], () => undefined);
34
- }
35
- else if (platform === 'win32') {
36
- execFile('cmd', ['/c', 'start', '', filePath], () => undefined);
37
- }
38
- else {
39
- execFile('xdg-open', [filePath], () => undefined);
40
- }
41
- }
42
- function isLocalOrigin(origin, port) {
43
- // Browsers send no Origin for same-document fetches from file:// pages in some
44
- // engines; when present it must be a localhost loopback matching our port, or
45
- // the literal "null" that file:// pages send. Anything else (a real website) is rejected.
46
- if (!origin || origin === 'null')
47
- return true;
48
- return (origin === `http://127.0.0.1:${port}` ||
49
- origin === `http://localhost:${port}`);
50
- }
51
- function startFeedbackServer(planName) {
52
- const token = crypto.randomBytes(32).toString('hex');
53
- const nonce = crypto.randomBytes(16).toString('hex');
54
- const server = http.createServer((req, res) => {
55
- // No wildcard CORS. Only same-origin loopback callers are honored.
56
- const origin = req.headers.origin;
57
- const port = server.address()?.port ?? 0;
58
- if (req.method === 'OPTIONS') {
59
- res.writeHead(204);
60
- res.end();
61
- return;
62
- }
63
- if (req.method !== 'POST' || !isLocalOrigin(origin, port)) {
64
- res.writeHead(403);
65
- res.end();
66
- return;
67
- }
68
- let body = '';
69
- req.on('data', (chunk) => {
70
- body += chunk.toString();
71
- if (body.length > 1000000)
72
- req.destroy();
73
- });
74
- req.on('end', () => {
75
- let parsed = {};
76
- try {
77
- parsed = JSON.parse(body);
78
- }
79
- catch { }
80
- // Constant-time-ish single-use token check.
81
- if (typeof parsed.token !== 'string' ||
82
- parsed.token.length !== token.length ||
83
- !crypto.timingSafeEqual(Buffer.from(parsed.token), Buffer.from(token))) {
84
- res.writeHead(403);
85
- res.end();
86
- return;
87
- }
88
- if (req.url === '/accept') {
89
- process.stdout.write(`\nPlan accepted: ${nonce}:${planName}\n`);
90
- res.writeHead(200, { 'Content-Type': 'application/json' });
91
- res.end(JSON.stringify({ ok: true }));
92
- server.close();
93
- return;
94
- }
95
- if (req.url === '/feedback') {
96
- const text = typeof parsed.feedback === 'string' ? parsed.feedback : '';
97
- const oneLine = text.replace(/[\r\n]+/g, ' ');
98
- process.stdout.write(`\nReceived user feedback: ${nonce}:${oneLine}\n`);
99
- res.writeHead(200, { 'Content-Type': 'application/json' });
100
- res.end(JSON.stringify({ ok: true }));
101
- server.close();
102
- return;
103
- }
104
- res.writeHead(404);
105
- res.end();
106
- });
107
- });
108
- server.listen(0, '127.0.0.1');
109
- const addr = server.address();
110
- if (!addr || typeof addr === 'string') {
111
- throw new Error('Failed to get server port');
112
- }
113
- registerPlanNonce(nonce);
114
- return { port: addr.port, token, nonce };
115
- }
116
- function buildPlanHtml(planName, markdownContent, feedbackPort, token) {
117
- const escaped = markdownContent
118
- .replace(/&/g, '&amp;')
119
- .replace(/</g, '&lt;')
120
- .replace(/>/g, '&gt;');
121
- const escapedPlanName = planName
122
- .replace(/&/g, '&amp;')
123
- .replace(/</g, '&lt;')
124
- .replace(/>/g, '&gt;')
125
- .replace(/"/g, '&quot;');
126
- return `<!DOCTYPE html>
127
- <html lang="en">
128
- <head>
129
- <meta charset="UTF-8" />
130
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
131
- <title>CodeMax Plan: ${escapedPlanName}</title>
132
- <style>
133
- * { box-sizing: border-box; margin: 0; padding: 0; }
134
- body {
135
- background: #0a0a0a;
136
- color: #e8e8e8;
137
- font-family: 'Courier New', monospace;
138
- font-size: 14px;
139
- line-height: 1.6;
140
- padding: 32px;
141
- max-width: 860px;
142
- margin: 0 auto;
143
- }
144
- h1, h2, h3 { color: #ff8c00; margin-top: 1.5em; margin-bottom: 0.5em; }
145
- h1 { font-size: 1.6em; border-bottom: 1px solid #ff8c00; padding-bottom: 0.3em; }
146
- h2 { font-size: 1.2em; }
147
- h3 { font-size: 1em; }
148
- pre {
149
- background: #141414;
150
- border: 1px solid #333;
151
- border-left: 3px solid #ff8c00;
152
- padding: 12px 16px;
153
- overflow-x: auto;
154
- margin: 1em 0;
155
- white-space: pre-wrap;
156
- word-break: break-word;
157
- }
158
- code { font-family: inherit; }
159
- p { margin: 0.8em 0; }
160
- ul, ol { margin: 0.8em 0 0.8em 1.5em; }
161
- li { margin: 0.3em 0; }
162
- a { color: #ff8c00; }
163
- blockquote { border-left: 3px solid #555; padding-left: 1em; color: #aaa; margin: 1em 0; }
164
- hr { border: none; border-top: 1px solid #333; margin: 1.5em 0; }
165
- .actions {
166
- position: sticky;
167
- bottom: 0;
168
- background: #0a0a0a;
169
- border-top: 1px solid #333;
170
- padding: 16px 0;
171
- display: flex;
172
- gap: 12px;
173
- align-items: flex-end;
174
- margin-top: 32px;
175
- }
176
- .btn-accept {
177
- background: #ff8c00;
178
- color: #0a0a0a;
179
- border: none;
180
- padding: 10px 24px;
181
- font-family: inherit;
182
- font-size: 14px;
183
- font-weight: bold;
184
- cursor: pointer;
185
- flex-shrink: 0;
186
- }
187
- .btn-accept:hover { background: #ffa733; }
188
- .btn-feedback {
189
- background: transparent;
190
- color: #ff8c00;
191
- border: 1px solid #ff8c00;
192
- padding: 10px 24px;
193
- font-family: inherit;
194
- font-size: 14px;
195
- cursor: pointer;
196
- flex-shrink: 0;
197
- }
198
- .btn-feedback:hover { background: #1a1000; }
199
- textarea {
200
- flex: 1;
201
- background: #141414;
202
- color: #e8e8e8;
203
- border: 1px solid #555;
204
- padding: 10px;
205
- font-family: inherit;
206
- font-size: 14px;
207
- resize: vertical;
208
- min-height: 42px;
209
- }
210
- textarea:focus { outline: 1px solid #ff8c00; border-color: #ff8c00; }
211
- .status { color: #aaa; font-size: 12px; min-width: 120px; text-align: right; }
212
- </style>
213
- </head>
214
- <body>
215
- <pre id="plan-content">${escaped}</pre>
216
-
217
- <div class="actions">
218
- <textarea id="feedback-input" placeholder="Give feedback to refine the plan…"></textarea>
219
- <button class="btn-feedback" onclick="sendFeedback()">Send Feedback</button>
220
- <button class="btn-accept" onclick="acceptPlan()">Accept Plan</button>
221
- <span class="status" id="status"></span>
222
- </div>
223
-
224
- <script>
225
- const PORT = ${feedbackPort};
226
- const TOKEN = ${JSON.stringify(token)};
227
- const BASE = 'http://127.0.0.1:' + PORT;
228
-
229
- async function acceptPlan() {
230
- document.getElementById('status').textContent = 'Accepting…';
231
- try {
232
- await fetch(BASE + '/accept', {
233
- method: 'POST',
234
- headers: {'Content-Type': 'application/json'},
235
- body: JSON.stringify({token: TOKEN}),
236
- });
237
- document.getElementById('status').textContent = 'Accepted!';
238
- document.querySelector('.btn-accept').disabled = true;
239
- document.querySelector('.btn-feedback').disabled = true;
240
- } catch(e) {
241
- document.getElementById('status').textContent = 'Error: ' + e.message;
242
- }
243
- }
244
-
245
- async function sendFeedback() {
246
- const text = document.getElementById('feedback-input').value.trim();
247
- if (!text) return;
248
- document.getElementById('status').textContent = 'Sending…';
249
- try {
250
- await fetch(BASE + '/feedback', {
251
- method: 'POST',
252
- headers: {'Content-Type': 'application/json'},
253
- body: JSON.stringify({token: TOKEN, feedback: text}),
254
- });
255
- document.getElementById('status').textContent = 'Feedback sent!';
256
- document.getElementById('feedback-input').value = '';
257
- } catch(e) {
258
- document.getElementById('status').textContent = 'Error: ' + e.message;
259
- }
260
- }
261
- </script>
262
- </body>
263
- </html>`;
264
- }
265
- function runShellCommand(command, cwd, timeoutMs) {
266
- return new Promise((resolve, reject) => {
267
- const child = spawn(command, {
268
- cwd,
269
- shell: true,
270
- env: process.env,
271
- stdio: ['ignore', 'pipe', 'pipe'],
272
- });
273
- let stdout = '';
274
- let stderr = '';
275
- let settled = false;
276
- const timer = setTimeout(() => {
277
- if (settled)
278
- return;
279
- settled = true;
280
- child.kill('SIGKILL');
281
- reject(new Error(`timeout after ${timeoutMs}ms`));
282
- }, timeoutMs);
283
- child.stdout?.on('data', (chunk) => {
284
- stdout += chunk.toString('utf8');
285
- });
286
- child.stderr?.on('data', (chunk) => {
287
- stderr += chunk.toString('utf8');
288
- });
289
- child.on('error', err => {
290
- if (settled)
291
- return;
292
- settled = true;
293
- clearTimeout(timer);
294
- reject(err);
295
- });
296
- child.on('close', (code, signal) => {
297
- if (settled)
298
- return;
299
- settled = true;
300
- clearTimeout(timer);
301
- const meta = [];
302
- if (code !== null)
303
- meta.push(`exit_code: ${code}`);
304
- if (signal)
305
- meta.push(`signal: ${signal}`);
306
- let body = '';
307
- if (stdout)
308
- body += `stdout:\n${stdout}`;
309
- if (stderr) {
310
- if (body)
311
- body += '\n';
312
- body += `stderr:\n${stderr}`;
313
- }
314
- if (!body)
315
- body = '(no output)';
316
- resolve(`${meta.join(', ')}\n\n${body}`);
317
- });
318
- });
319
- }
320
- const shellTool = tool(async ({ command, cwd, timeout_ms }, _config) => {
321
- const wd = (cwd?.trim() || DEFAULT_SHELL_CWD).trim();
322
- const timeoutMs = timeout_ms ?? DEFAULT_SHELL_TIMEOUT_MS;
323
- try {
324
- const raw = await runShellCommand(command.trim(), wd, timeoutMs);
325
- return truncateOutput(raw, MAX_SHELL_OUTPUT_CHARS);
326
- }
327
- catch (e) {
328
- const msg = e instanceof Error ? e.message : String(e);
329
- return `Failed to execute shell command: ${msg}`;
330
- }
331
- }, {
332
- name: 'shellTool',
333
- description: 'Runs a command in the system shell (Unix: /bin/sh -c). Use for git, pnpm/npm, listing files, grep, etc. Prefer non-interactive commands. Default working directory is the session cwd (or CODEMAX_SHELL_CWD).',
334
- schema: z.object({
335
- command: z
336
- .string()
337
- .min(1)
338
- .describe('Shell command to run (single line; pipes and && chains are allowed).'),
339
- cwd: z
340
- .string()
341
- .optional()
342
- .describe('Working directory (absolute or relative path); omit to use the default cwd.'),
343
- timeout_ms: z
344
- .number()
345
- .int()
346
- .positive()
347
- .max(600000)
348
- .optional()
349
- .describe('Timeout in milliseconds (max 600000). Defaults to CODEMAX_SHELL_TIMEOUT_MS or 120000.'),
350
- }),
351
- });
352
- // Strip outputSchema from MCP tools/list responses before the adapter processes them.
353
- // Some servers (e.g. stitch) return complex $defs-based outputSchemas that @cfworker/json-schema
354
- // can't resolve, crashing tool loading. outputSchema is not used for tool invocation.
355
- function withOutputSchemaPatch(fn) {
356
- const originalFetch = globalThis.fetch;
357
- globalThis.fetch = async (input, init) => {
358
- const res = await originalFetch(input, init);
359
- if (res.status === 401 || res.status === 403) {
360
- throw new AuthError(`HTTP ${res.status}`);
361
- }
362
- const contentType = res.headers.get('content-type') ?? '';
363
- if (!contentType.includes('application/json'))
364
- return res;
365
- const text = await res.text();
366
- try {
367
- const body = JSON.parse(text);
368
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
369
- const tools = body?.result?.tools;
370
- if (Array.isArray(tools)) {
371
- for (const t of tools)
372
- delete t.outputSchema;
373
- return new Response(JSON.stringify(body), {
374
- status: res.status,
375
- statusText: res.statusText,
376
- headers: res.headers,
377
- });
378
- }
379
- }
380
- catch { }
381
- return new Response(text, {
382
- status: res.status,
383
- statusText: res.statusText,
384
- headers: res.headers,
385
- });
386
- };
387
- return fn().finally(() => {
388
- globalThis.fetch = originalFetch;
389
- });
390
- }
391
- async function loadServerTools(
392
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
393
- name, config) {
394
- const mcpConfig = config;
395
- const timeout = new Promise((_, reject) => {
396
- setTimeout(() => reject(new Error(`MCP connection timeout (${MCP_TIMEOUT_MS}ms)`)), MCP_TIMEOUT_MS);
397
- });
398
- try {
399
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
400
- const client = new MultiServerMCPClient({
401
- mcpServers: { [name]: config },
402
- onConnectionError: 'ignore',
403
- });
404
- const tools = (await Promise.race([
405
- withOutputSchemaPatch(() => client.getTools()),
406
- timeout,
407
- ]));
408
- return {
409
- tools,
410
- status: {
411
- name,
412
- config: mcpConfig,
413
- status: 'connected',
414
- toolCount: tools.length,
415
- },
416
- };
417
- }
418
- catch (e) {
419
- const isAuth = e instanceof AuthError;
420
- const msg = e instanceof Error ? e.message : String(e);
421
- if (isAuth) {
422
- process.stderr.write(`[CodeMax] MCP server "${name}" requires authentication: ${msg}\n`);
423
- }
424
- else {
425
- process.stderr.write(`[CodeMax] MCP server "${name}" unavailable: ${msg}\n`);
426
- }
427
- return {
428
- tools: [],
429
- status: {
430
- name,
431
- config: mcpConfig,
432
- status: isAuth ? 'unauthenticated' : 'unavailable',
433
- toolCount: 0,
434
- error: msg,
435
- },
436
- };
437
- }
438
- }
439
- async function loadMcpTools() {
440
- const configServers = loadMcpServers();
441
- const hasConfigServers = Object.keys(configServers).length > 0;
442
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
443
- const serversToLoad = hasConfigServers
444
- ? configServers
445
- : {
446
- playwright: {
447
- transport: 'http',
448
- url: PLAYWRIGHT_MCP_URL,
449
- defaultToolTimeout: 25000,
450
- },
451
- };
452
- // Load servers sequentially — fetch is patched per-server to strip outputSchema,
453
- // so concurrent loading would cause patch collisions.
454
- const results = [];
455
- for (const [name, config] of Object.entries(serversToLoad)) {
456
- // eslint-disable-next-line no-await-in-loop
457
- results.push(await loadServerTools(name, config));
458
- }
459
- const tools = results.flatMap(r => r.tools);
460
- const statuses = results.map(r => r.status);
461
- if (hasConfigServers) {
462
- process.stderr.write(`[CodeMax] Loaded ${tools.length} MCP tool(s) from config.\n`);
463
- }
464
- return { tools, statuses };
465
- }
466
- export async function reloadMcpServer(name) {
467
- const configServers = loadMcpServers();
468
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
469
- const config = configServers[name] ?? {
470
- transport: 'http',
471
- url: PLAYWRIGHT_MCP_URL,
472
- defaultToolTimeout: 25000,
473
- };
474
- const { status } = await loadServerTools(name, config);
475
- return status;
476
- }
477
- const readFileTool = tool(async ({ filePath, startLine, endLine }) => {
478
- const content = await readFile(filePath, 'utf8');
479
- if (startLine === undefined && endLine === undefined)
480
- return content;
481
- const lines = content.split('\n');
482
- const start = (startLine ?? 1) - 1;
483
- const end = endLine ?? lines.length;
484
- return lines.slice(start, end).join('\n');
485
- }, {
486
- name: 'readFileTool',
487
- description: 'Reads a UTF-8 text file from the filesystem. Use startLine/endLine to read only a portion of the file. If unsure how many lines are needed, read the first 20 lines first.',
488
- schema: z.object({
489
- filePath: z.string().describe('Path to the file to read.'),
490
- startLine: z
491
- .number()
492
- .int()
493
- .positive()
494
- .optional()
495
- .describe('1-based line number to start reading from (inclusive).'),
496
- endLine: z
497
- .number()
498
- .int()
499
- .positive()
500
- .optional()
501
- .describe('1-based line number to stop reading at (inclusive).'),
502
- }),
503
- });
504
- const writeFileTool = tool(async ({ filePath, content, append }) => {
505
- await writeFile(filePath, content, {
506
- encoding: 'utf8',
507
- flag: append ? 'a' : 'w',
508
- });
509
- return `File ${filePath} written successfully`;
510
- }, {
511
- name: 'writeFileTool',
512
- description: 'Writes a UTF-8 text file to the filesystem.',
513
- schema: z.object({
514
- filePath: z.string().describe('Path to the file to write.'),
515
- content: z.string().describe('Content to write to the file.'),
516
- append: z
517
- .boolean()
518
- .default(false)
519
- .describe('Whether to only append to the end of the file or not.'),
520
- }),
521
- });
522
- const editFileTool = tool(async ({ filePath, start, end, content }) => {
523
- const fullContent = await readFile(filePath, 'utf8');
524
- const lines = fullContent.split('\n');
525
- // Lines are 1-indexed
526
- const startIdx = Math.max(0, start - 1);
527
- const endIdx = Math.min(lines.length, end);
528
- const newLines = [
529
- ...lines.slice(0, startIdx),
530
- ...content.split('\n'),
531
- ...lines.slice(endIdx),
532
- ];
533
- await writeFile(filePath, newLines.join('\n'), 'utf8');
534
- return `File ${filePath} edited from line ${start} to ${end}.`;
535
- }, {
536
- name: 'editFileTool',
537
- description: 'Edit a file by replacing a range of lines with new content.',
538
- schema: z.object({
539
- filePath: z.string().describe('Path to the file to edit.'),
540
- start: z
541
- .number()
542
- .int()
543
- .positive()
544
- .describe('Starting line number (1-indexed).'),
545
- end: z
546
- .number()
547
- .int()
548
- .positive()
549
- .describe('Ending line number (inclusive, 1-indexed).'),
550
- content: z.string().describe('New content to insert in the range.'),
551
- }),
552
- });
553
- const todoTool = tool(async ({ action: _action, id: _id, task: _task, status: _status }) => {
554
- // The logic is handled in the toolNode in codemax.ts
555
- return 'Todo list updated.';
556
- }, {
557
- name: 'todoTool',
558
- description: 'Manage a TODO list for the current task. Actions: add, update, remove, list.',
559
- schema: z.object({
560
- action: z.enum(['add', 'update', 'remove', 'list']),
561
- id: z.string().optional().describe('Required for update and remove.'),
562
- task: z.string().optional().describe('Required for add.'),
563
- status: z
564
- .enum(['pending', 'in_progress', 'completed'])
565
- .optional()
566
- .describe('Optional for add and update.'),
567
- }),
568
- });
569
- export const writePlanTool = tool(async ({ planName, content }) => {
570
- const plansDir = path.join(os.homedir(), '.codemax', 'plans');
571
- await fs.promises.mkdir(plansDir, { recursive: true });
572
- const mdPath = path.join(plansDir, `${planName}.md`);
573
- const htmlPath = path.join(plansDir, `${planName}.html`);
574
- // Defense in depth: even with the strict schema, confirm the resolved paths
575
- // stay inside plansDir before writing.
576
- const dirPrefix = path.resolve(plansDir) + path.sep;
577
- if (!path.resolve(mdPath).startsWith(dirPrefix) ||
578
- !path.resolve(htmlPath).startsWith(dirPrefix)) {
579
- throw new Error('Invalid planName: resolved path escapes the plans directory');
580
- }
581
- await fs.promises.writeFile(mdPath, content, 'utf8');
582
- const { port, token } = startFeedbackServer(planName);
583
- const html = buildPlanHtml(planName, content, port, token);
584
- await fs.promises.writeFile(htmlPath, html, 'utf8');
585
- openInBrowser(htmlPath);
586
- return `Plan written to ${mdPath}\nHTML preview opened in browser (feedback server on port ${port})`;
587
- }, {
588
- name: 'writePlanTool',
589
- description: 'Write an implementation plan as a Markdown file and open it in the browser for review. Only available in Plan Mode. planName must have no spaces.',
590
- schema: z.object({
591
- planName: z
592
- .string()
593
- .regex(/^[A-Za-z0-9._-]{1,64}$/, 'planName must be 1-64 chars of letters, digits, dot, underscore or hyphen')
594
- .describe('Plan file name without extension, e.g. "add-auth-feature"'),
595
- content: z.string().describe('Full plan content in Markdown'),
596
- }),
597
- });
598
- const { tools: _mcpTools, statuses: _mcpStatuses } = await loadMcpTools();
599
- export const MCP_STATUSES = _mcpStatuses;
600
- export const TOOLS = [
601
- shellTool,
602
- readFileTool,
603
- writeFileTool,
604
- editFileTool,
605
- todoTool,
606
- ..._mcpTools,
607
- ];
608
- export const PLAN_TOOLS = [
609
- shellTool,
610
- readFileTool,
611
- writePlanTool,
612
- todoTool,
613
- ..._mcpTools,
614
- ];