@myvillage/cli 1.23.3 → 1.28.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myvillage/cli",
3
- "version": "1.23.3",
3
+ "version": "1.28.1",
4
4
  "description": "MyVillageOS CLI for community developers",
5
5
  "type": "module",
6
6
  "bin": {
@@ -26,7 +26,7 @@
26
26
  "author": "MyVillage Project",
27
27
  "license": "MIT",
28
28
  "dependencies": {
29
- "@ai-sdk/anthropic": "^1.0.0",
29
+ "@ai-sdk/anthropic": "^3.0.78",
30
30
  "@ai-sdk/mcp": "^1.0.42",
31
31
  "@ai-sdk/openai": "^3.0.33",
32
32
  "ai": "^6.0.0",
@@ -4,6 +4,19 @@
4
4
  // It receives the agent name as a command-line argument,
5
5
  // writes a PID file, runs the agent loop, and cleans up.
6
6
 
7
+ // Surface silent killers (uncaught exceptions, unhandled rejections, and
8
+ // event-loop-emptied exits) to the captured stderr file so a failed start
9
+ // always leaves a diagnostic trail.
10
+ process.on('uncaughtException', (err) => {
11
+ process.stderr.write(`[daemon-entry] UNCAUGHT EXCEPTION: ${err?.stack || err}\n`);
12
+ });
13
+ process.on('unhandledRejection', (reason) => {
14
+ process.stderr.write(`[daemon-entry] UNHANDLED REJECTION: ${reason?.stack || reason}\n`);
15
+ });
16
+ process.on('beforeExit', (code) => {
17
+ process.stderr.write(`[daemon-entry] BEFORE EXIT code=${code} — event loop emptied (likely a hung await inside agent setup)\n`);
18
+ });
19
+
7
20
  import { writeFileSync, unlinkSync, existsSync, mkdirSync, appendFileSync } from 'fs';
8
21
  import { join } from 'path';
9
22
  import { homedir } from 'os';
@@ -5,16 +5,35 @@
5
5
  import { fork } from 'child_process';
6
6
  import { join, dirname } from 'path';
7
7
  import { fileURLToPath } from 'url';
8
- import { existsSync, readFileSync, unlinkSync } from 'fs';
8
+ import { existsSync, readFileSync, unlinkSync, openSync, mkdirSync } from 'fs';
9
9
  import { homedir } from 'os';
10
10
 
11
11
  const __dirname = dirname(fileURLToPath(import.meta.url));
12
12
 
13
+ export function getDaemonStderrPath(agentName) {
14
+ return join(homedir(), '.myvillage', 'agents', agentName, 'logs', 'daemon-stderr.log');
15
+ }
16
+
13
17
  export function startDaemon(agentName) {
14
18
  const entryScript = join(__dirname, 'daemon-entry.js');
19
+
20
+ // Capture daemon stderr to a file so crashes that happen BEFORE the loop
21
+ // starts logging (e.g. ESM module-resolution errors at top-level import)
22
+ // are visible. Without this, `stdio: 'ignore'` silently discarded the
23
+ // only signal of what went wrong, leaving the developer with a blank
24
+ // log file and no error.
25
+ const logsDir = join(homedir(), '.myvillage', 'agents', agentName, 'logs');
26
+ if (!existsSync(logsDir)) {
27
+ mkdirSync(logsDir, { recursive: true });
28
+ }
29
+ const stderrPath = getDaemonStderrPath(agentName);
30
+ // Open with 'w' to truncate so stale crashes from previous starts don't
31
+ // mislead the diagnostic.
32
+ const stderrFd = openSync(stderrPath, 'w');
33
+
15
34
  const child = fork(entryScript, [agentName], {
16
35
  detached: true,
17
- stdio: 'ignore',
36
+ stdio: ['ignore', 'ignore', stderrFd, 'ipc'],
18
37
  });
19
38
  child.unref();
20
39
  return child.pid;
@@ -2,7 +2,7 @@
2
2
  // Core agent loop using Vercel AI SDK. Reads prompt.md,
3
3
  // gathers context, calls LLM with tools, logs results.
4
4
 
5
- import { generateText } from 'ai';
5
+ import { generateText, stepCountIs } from 'ai';
6
6
  import { createAnthropic } from '@ai-sdk/anthropic';
7
7
  import { createOpenAI } from '@ai-sdk/openai';
8
8
  import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync } from 'fs';
@@ -176,7 +176,15 @@ export async function agentLoop(agentName, { signal }) {
176
176
  const instructionText = activeTask.instruction
177
177
  || (activeTask.input ? JSON.stringify(activeTask.input, null, 2) : '');
178
178
 
179
- systemPrompt = `${systemPrompt}\n\n## ACTIVE TASK MODE\nA client has assigned you a task. Your job this iteration is to execute the instruction below using your available tools. The feed context is provided only for situational awareness — do not let "no new feed activity" prevent you from carrying out the task.`;
179
+ systemPrompt = `${systemPrompt}\n\n## ACTIVE TASK MODE
180
+ A client has assigned you the task below. Your job this iteration is to **execute it fully** using your available tools, not just acknowledge it.
181
+
182
+ Guidelines:
183
+ - Use as many tool calls as needed to complete the task. A single discovery call (e.g. listing directories) is usually NOT enough — follow up with the calls that actually produce the requested result.
184
+ - Do not stop after one tool call unless that call alone fulfills the entire instruction.
185
+ - Do not ask clarifying questions back to the user; make a reasonable interpretation and proceed.
186
+ - The feed context is provided only for situational awareness — do not let "no new feed activity" prevent you from carrying out the task.
187
+ - Your final text response should include the actual answer/output the user asked for (e.g. the list of files, the summary, the result), not just a description of what you did.`;
180
188
 
181
189
  context = `## TASK (id=${activeTask.id}, type=${activeTask.taskType})\n${instructionText}\n\n---\n\n## FEED CONTEXT (for awareness only)\n${context}`;
182
190
 
@@ -193,13 +201,20 @@ export async function agentLoop(agentName, { signal }) {
193
201
  });
194
202
 
195
203
  // Call LLM with tools
204
+ // AI SDK v6 removed `maxSteps` — the loop now uses `stopWhen` with
205
+ // a StopCondition predicate. The default is `stepCountIs(1)`, so
206
+ // without this the model gets exactly ONE round of tool calls and
207
+ // returns immediately. We give it up to 10 steps so a task like
208
+ // "find any README under the workspace" can do
209
+ // list_allowed_directories → list_directory → search_files → ...
210
+ // until it's actually done.
196
211
  const result = await generateText({
197
212
  model,
198
213
  system: systemPrompt,
199
214
  prompt: context,
200
215
  tools,
201
- maxSteps: 5,
202
- maxTokens,
216
+ stopWhen: stepCountIs(10),
217
+ maxOutputTokens: maxTokens,
203
218
  });
204
219
 
205
220
  // Log LLM response
@@ -207,13 +222,18 @@ export async function agentLoop(agentName, { signal }) {
207
222
  type: 'llm_response',
208
223
  text: (result.text || '').slice(0, 500),
209
224
  tokensUsed: {
210
- prompt: result.usage?.promptTokens || 0,
211
- completion: result.usage?.completionTokens || 0,
225
+ prompt: result.usage?.inputTokens || 0,
226
+ completion: result.usage?.outputTokens || 0,
227
+ total: result.usage?.totalTokens || 0,
212
228
  },
213
229
  });
214
230
 
215
231
  // Log tool calls and count activity. Also audit action-tool success
216
232
  // so we don't trust the model's final text about whether a task worked.
233
+ // `toolCallSummary` accumulates a structured per-call record that we
234
+ // attach to the task output so the Activity panel can show what the
235
+ // agent actually did — not just a tool-call count.
236
+ const toolCallSummary = [];
217
237
  if (result.steps?.length) {
218
238
  for (const step of result.steps) {
219
239
  if (step.toolCalls?.length) {
@@ -227,25 +247,39 @@ export async function agentLoop(agentName, { signal }) {
227
247
  if (step.toolCalls?.length && step.toolResults?.length) {
228
248
  for (let i = 0; i < step.toolResults.length; i++) {
229
249
  const tr = step.toolResults[i];
230
- const args = step.toolCalls[i]?.args;
250
+ // AI SDK v6 renamed `args` `input`. Try v6 first.
251
+ const args = step.toolCalls[i]?.input ?? step.toolCalls[i]?.args;
231
252
  const errored = isToolResultError(tr);
253
+ const resultSummary = summarizeToolResult(tr);
232
254
  auditToolCall(taskActionAudit, tr.toolName, errored, tr);
255
+ toolCallSummary.push({
256
+ tool: tr.toolName,
257
+ args,
258
+ result: resultSummary,
259
+ ok: !errored,
260
+ });
233
261
  logActivity(agentDir, {
234
262
  type: 'tool_call',
235
263
  tool: tr.toolName,
236
264
  args,
237
- result: summarizeToolResult(tr),
265
+ result: resultSummary,
238
266
  ok: !errored,
239
267
  });
240
268
  }
241
269
  } else if (step.toolResults?.length) {
242
270
  for (const tr of step.toolResults) {
243
271
  const errored = isToolResultError(tr);
272
+ const resultSummary = summarizeToolResult(tr);
244
273
  auditToolCall(taskActionAudit, tr.toolName, errored, tr);
274
+ toolCallSummary.push({
275
+ tool: tr.toolName,
276
+ result: resultSummary,
277
+ ok: !errored,
278
+ });
245
279
  logActivity(agentDir, {
246
280
  type: 'tool_call',
247
281
  tool: tr.toolName,
248
- result: summarizeToolResult(tr),
282
+ result: resultSummary,
249
283
  ok: !errored,
250
284
  });
251
285
  }
@@ -258,10 +292,16 @@ export async function agentLoop(agentName, { signal }) {
258
292
  if (tc.toolName === 'comment_create') activity.commentsCreated++;
259
293
  if (tc.toolName === 'vote_cast') activity.votesGiven++;
260
294
  // No paired result here — assume executed, can't audit.
295
+ const args = tc.input ?? tc.args;
296
+ toolCallSummary.push({
297
+ tool: tc.toolName,
298
+ args,
299
+ result: 'executed',
300
+ });
261
301
  logActivity(agentDir, {
262
302
  type: 'tool_call',
263
303
  tool: tc.toolName,
264
- args: tc.args,
304
+ args,
265
305
  result: 'executed',
266
306
  });
267
307
  }
@@ -280,12 +320,12 @@ export async function agentLoop(agentName, { signal }) {
280
320
  if (result.steps?.length) {
281
321
  for (const step of result.steps) {
282
322
  for (const tc of step.toolCalls || []) {
283
- collectActions(tc.toolName, tc.args);
323
+ collectActions(tc.toolName, tc.input ?? tc.args);
284
324
  }
285
325
  }
286
326
  } else if (result.toolCalls?.length) {
287
327
  for (const tc of result.toolCalls) {
288
- collectActions(tc.toolName, tc.args);
328
+ collectActions(tc.toolName, tc.input ?? tc.args);
289
329
  }
290
330
  }
291
331
  // Keep only last 50 actions to bound memory
@@ -309,11 +349,12 @@ export async function agentLoop(agentName, { signal }) {
309
349
  errorMessage,
310
350
  output: {
311
351
  text: result.text || '',
312
- toolCalls: activity.toolCalls,
352
+ toolCallCount: activity.toolCalls,
353
+ toolCalls: toolCallSummary,
313
354
  toolErrors: taskActionAudit.toolErrors,
314
355
  note: 'Marked FAILED because the action tools did not succeed. The model\'s text may claim success but the underlying tool calls errored.',
315
356
  },
316
- tokensUsed: (result.usage?.promptTokens || 0) + (result.usage?.completionTokens || 0),
357
+ tokensUsed: result.usage?.totalTokens || 0,
317
358
  durationMs: Date.now() - loopStart,
318
359
  });
319
360
  logActivity(agentDir, {
@@ -325,10 +366,11 @@ export async function agentLoop(agentName, { signal }) {
325
366
  await completeAgentTask(config.man.village_agent_id, activeTask.id, {
326
367
  output: {
327
368
  text: result.text || '',
328
- toolCalls: activity.toolCalls,
369
+ toolCallCount: activity.toolCalls,
370
+ toolCalls: toolCallSummary,
329
371
  toolErrors: taskActionAudit.toolErrors.length > 0 ? taskActionAudit.toolErrors : undefined,
330
372
  },
331
- tokensUsed: (result.usage?.promptTokens || 0) + (result.usage?.completionTokens || 0),
373
+ tokensUsed: result.usage?.totalTokens || 0,
332
374
  durationMs: Date.now() - loopStart,
333
375
  });
334
376
  logActivity(agentDir, { type: 'task_completed', taskId: activeTask.id });
@@ -347,7 +389,7 @@ export async function agentLoop(agentName, { signal }) {
347
389
  votesGiven: activity.votesGiven,
348
390
  toolCalls: activity.toolCalls,
349
391
  modelUsed: modelId,
350
- tokensUsed: (result.usage?.promptTokens || 0) + (result.usage?.completionTokens || 0),
392
+ tokensUsed: result.usage?.totalTokens || 0,
351
393
  durationMs: Date.now() - loopStart,
352
394
  activitySummary: { feedItemsRead, mentionsFound },
353
395
  });
@@ -451,7 +493,10 @@ const ACTION_TOOLS = new Set([
451
493
 
452
494
  function flattenToolResultText(tr) {
453
495
  if (!tr) return '';
454
- const r = tr.result;
496
+ // AI SDK v6 renamed `result` → `output`. Fall back to the old name so
497
+ // older SDK versions still work if anyone pins them.
498
+ const r = tr.output ?? tr.result;
499
+ if (r == null) return '';
455
500
  if (typeof r === 'string') return r;
456
501
  if (Array.isArray(r?.content)) {
457
502
  return r.content
@@ -33,7 +33,10 @@ export async function getMCPTools(agentDir, agentConfig) {
33
33
  continue;
34
34
  }
35
35
 
36
- const transport = server.url ? 'sse' : 'stdio';
36
+ // URL-based servers default to Streamable HTTP (the current MCP
37
+ // standard), but allow `transport: 'sse'` in tools.yaml for legacy
38
+ // SSE-only servers.
39
+ const transport = server.url ? (server.transport || 'http') : 'stdio';
37
40
 
38
41
  try {
39
42
  // AI SDK v6 moved the MCP client into a separate package
@@ -55,34 +58,67 @@ export async function getMCPTools(agentDir, agentConfig) {
55
58
  + `npm uninstall -g @myvillage/cli && npm install -g @myvillage/cli`
56
59
  );
57
60
  }
61
+ // Wrap createMCPClient with a hard timeout. Without this, @ai-sdk/mcp's
62
+ // SSE transport can return a never-settling promise when the endpoint
63
+ // doesn't respond as expected — and because nothing else keeps the
64
+ // event loop alive during agent startup, Node will exit cleanly with
65
+ // code 0, bypassing every try/catch and .finally we've set up.
66
+ const MCP_CONNECT_TIMEOUT_MS = 30000;
67
+ const withTimeout = (promise, label) => Promise.race([
68
+ promise,
69
+ new Promise((_, reject) => setTimeout(
70
+ () => reject(new Error(`MCP connect timed out after ${MCP_CONNECT_TIMEOUT_MS}ms (${label})`)),
71
+ MCP_CONNECT_TIMEOUT_MS,
72
+ )),
73
+ ]);
58
74
  let client;
59
75
 
60
76
  if (server.url) {
61
77
  // Remote MCP server via Streamable HTTP transport
62
78
  const headers = {};
63
- if (process.env.MYVILLAGE_ACCESS_TOKEN) {
64
- headers['Authorization'] = `Bearer ${process.env.MYVILLAGE_ACCESS_TOKEN}`;
65
- }
66
- if (process.env.MYVILLAGE_AGENT_ID) {
67
- headers['X-Agent-Id'] = process.env.MYVILLAGE_AGENT_ID;
79
+
80
+ // Auto-attach the MyVillage bearer ONLY for MyVillage hostnames.
81
+ // Sending an in-session OAuth token to an arbitrary third-party
82
+ // MCP would be a credential leak.
83
+ if (isMyVillageHost(server.url)) {
84
+ if (process.env.MYVILLAGE_ACCESS_TOKEN) {
85
+ headers['Authorization'] = `Bearer ${process.env.MYVILLAGE_ACCESS_TOKEN}`;
86
+ }
87
+ if (process.env.MYVILLAGE_AGENT_ID) {
88
+ headers['X-Agent-Id'] = process.env.MYVILLAGE_AGENT_ID;
89
+ }
68
90
  }
69
- client = await createMCPClient({
91
+
92
+ // Per-server headers from tools.yaml (with `${VAR}` env expansion).
93
+ // These win over the auto-attached MyVillage headers so a developer
94
+ // can override on the same host if they need to.
95
+ Object.assign(headers, resolveEnvVars(server.headers || {}));
96
+
97
+ client = await withTimeout(createMCPClient({
70
98
  transport: {
71
- type: 'sse',
99
+ type: transport,
72
100
  url: server.url,
73
101
  headers,
74
102
  },
75
- });
103
+ }), `${name} (${transport} ${server.url})`);
76
104
  } else {
77
- // Local MCP servers via stdio transport
78
- client = await createMCPClient({
79
- transport: {
80
- type: 'stdio',
81
- command: server.command,
82
- args: server.args || [],
83
- env: { ...process.env, ...resolveEnvVars(server.env || {}) },
84
- },
105
+ // Local MCP servers via stdio transport.
106
+ //
107
+ // @ai-sdk/mcp v1 doesn't accept a `{ type: 'stdio', ... }` config —
108
+ // its MCPTransportConfig is HTTP-only (`'sse' | 'http'`). For stdio
109
+ // you must instantiate the transport class explicitly from the
110
+ // `/mcp-stdio` subpath and pass the instance.
111
+ const { Experimental_StdioMCPTransport } =
112
+ await import('@ai-sdk/mcp/mcp-stdio');
113
+ const stdioTransport = new Experimental_StdioMCPTransport({
114
+ command: server.command,
115
+ args: server.args || [],
116
+ env: { ...process.env, ...resolveEnvVars(server.env || {}) },
85
117
  });
118
+ client = await withTimeout(
119
+ createMCPClient({ transport: stdioTransport }),
120
+ `${name} (stdio ${server.command})`,
121
+ );
86
122
  }
87
123
 
88
124
  activeClients.push(client);
@@ -121,6 +157,15 @@ export async function cleanupMCPClients() {
121
157
 
122
158
  // ── Helpers ─────────────────────────────────────────────
123
159
 
160
+ function isMyVillageHost(urlString) {
161
+ try {
162
+ const host = new URL(urlString).hostname;
163
+ return host === 'myvillageproject.ai' || host.endsWith('.myvillageproject.ai');
164
+ } catch {
165
+ return false;
166
+ }
167
+ }
168
+
124
169
  function resolveEnvVars(envMap) {
125
170
  const resolved = {};
126
171
  for (const [key, val] of Object.entries(envMap)) {
@@ -4,6 +4,7 @@ import { villageSpinner, brand } from '../utils/brand.js';
4
4
  import inquirer from 'inquirer';
5
5
  import { existsSync, readFileSync } from 'fs';
6
6
  import { join } from 'path';
7
+ import { homedir } from 'os';
7
8
  import { execSync } from 'child_process';
8
9
  import { isAuthenticated, getAccessToken } from '../utils/auth.js';
9
10
  import { getConfig, setConfig } from '../utils/config.js';
@@ -41,7 +42,7 @@ import {
41
42
  readAgentLogs,
42
43
  getLogFilePath,
43
44
  } from '../utils/local-agent.js';
44
- import { scaffoldAgent, TOOL_CATALOG } from '../utils/agent-scaffolder.js';
45
+ import { scaffoldAgent, TOOL_CATALOG, resolveCatalogEntry } from '../utils/agent-scaffolder.js';
45
46
  import { formatLocalAgentList, formatLocalAgentStatus } from '../utils/formatters.js';
46
47
 
47
48
  // ── Create Local Agent (wizard) ─────────────────────────
@@ -117,11 +118,10 @@ export async function agentCreateLocalCommand() {
117
118
  message: 'Which tools should your agent have?',
118
119
  choices: [
119
120
  { name: 'MyVillageOS MCP (feed, posts, communities, wallet)', value: 'myvillage', checked: true, disabled: 'always enabled' },
120
- { name: 'Local Files (read/write project files)', value: 'filesystem', checked: true },
121
+ { name: 'Local Files (read/write a sandboxed workspace)', value: 'filesystem', checked: true },
121
122
  { name: 'Gmail', value: 'gmail' },
122
123
  { name: 'Calendar', value: 'calendar' },
123
124
  { name: 'GitHub', value: 'github' },
124
- { name: 'Browser', value: 'browser' },
125
125
  ],
126
126
  },
127
127
  {
@@ -396,7 +396,7 @@ export async function agentStartCommand(name) {
396
396
  const toolsConfig = readToolsYaml(name);
397
397
  if (toolsConfig.servers?.['man-feed'] && !toolsConfig.servers?.['myvillage']) {
398
398
  delete toolsConfig.servers['man-feed'];
399
- toolsConfig.servers = { myvillage: TOOL_CATALOG['myvillage'], ...toolsConfig.servers };
399
+ toolsConfig.servers = { myvillage: resolveCatalogEntry('myvillage', name), ...toolsConfig.servers };
400
400
  writeToolsYaml(name, toolsConfig);
401
401
  }
402
402
 
@@ -416,7 +416,7 @@ export async function agentStartCommand(name) {
416
416
  const spinner = villageSpinner(`Starting agent "${name}"...`).start();
417
417
 
418
418
  try {
419
- const { startDaemon } = await import('../agent-runtime/daemon.js');
419
+ const { startDaemon, getDaemonStderrPath } = await import('../agent-runtime/daemon.js');
420
420
  startDaemon(name);
421
421
 
422
422
  // Wait briefly for PID file to appear
@@ -437,6 +437,25 @@ export async function agentStartCommand(name) {
437
437
  const lastError = [...recent].reverse().find(e => e.type === 'error');
438
438
  if (lastError?.error) {
439
439
  console.log(chalk.red(` ${lastError.error}`));
440
+ } else {
441
+ // Daemon died before it could write a single log entry. Fall back
442
+ // to the stderr capture file — catches top-level ESM resolution
443
+ // errors, missing modules, syntax errors, etc.
444
+ const stderrPath = getDaemonStderrPath(name);
445
+ if (existsSync(stderrPath)) {
446
+ try {
447
+ const stderrText = readFileSync(stderrPath, 'utf-8').trim();
448
+ if (stderrText) {
449
+ console.log(chalk.red(' Daemon stderr:'));
450
+ // Indent each line for readability and cap output length so a
451
+ // multi-MB stack trace doesn't drown the terminal.
452
+ const trimmed = stderrText.length > 2000
453
+ ? stderrText.slice(0, 2000) + '\n... (truncated; see ' + stderrPath + ')'
454
+ : stderrText;
455
+ console.log(trimmed.split('\n').map(l => ' ' + l).join('\n'));
456
+ }
457
+ } catch { /* ignore */ }
458
+ }
440
459
  }
441
460
  console.log(brand.teal(` Full logs: myvillage agent logs ${name}\n`));
442
461
  }
@@ -629,9 +648,22 @@ function printLogEntry(entry) {
629
648
  console.log(` ${' '.repeat(10)} ${brand.teal(`Tokens: ${entry.tokensUsed.prompt || 0}p / ${entry.tokensUsed.completion || 0}c`)}`);
630
649
  }
631
650
  break;
632
- case 'tool_call':
633
- console.log(` ${time} ${chalk.yellow('TOOL')} ${entry.tool || '?'} ${brand.teal(`→ ${entry.result || 'ok'}`)}`);
651
+ case 'tool_call': {
652
+ const arrow = entry.ok === false ? chalk.red('') : brand.teal('');
653
+ const statusLabel = entry.ok === false ? chalk.red('error') : '';
654
+ console.log(` ${time} ${chalk.yellow('TOOL')} ${entry.tool || '?'} ${arrow} ${statusLabel}`);
655
+ // Show args (if any) and the result content on indented sub-lines,
656
+ // so a developer tailing logs can actually see what the agent did.
657
+ if (entry.args && Object.keys(entry.args).length > 0) {
658
+ const argsStr = truncateLog(JSON.stringify(entry.args), 200);
659
+ console.log(` ${' '.repeat(10)} ${brand.teal('args:')} ${argsStr}`);
660
+ }
661
+ if (entry.result && entry.result !== 'ok' && entry.result !== 'executed') {
662
+ const resultStr = truncateLog(String(entry.result), 200);
663
+ console.log(` ${' '.repeat(10)} ${brand.teal('result:')} ${resultStr}`);
664
+ }
634
665
  break;
666
+ }
635
667
  case 'error':
636
668
  console.log(` ${time} ${chalk.red('ERR')} ${entry.error || entry.message || 'Unknown error'}`);
637
669
  break;
@@ -691,11 +723,15 @@ export async function agentAddToolCommand(name, tool) {
691
723
  }
692
724
 
693
725
  toolsConfig.servers = toolsConfig.servers || {};
694
- toolsConfig.servers[tool] = { ...TOOL_CATALOG[tool] };
726
+ toolsConfig.servers[tool] = resolveCatalogEntry(tool, name);
695
727
  writeToolsYaml(name, toolsConfig);
696
728
 
697
729
  console.log(brand.green(` \u2713 Added "${tool}" to agent "${name}".`));
698
730
 
731
+ if (tool === 'filesystem') {
732
+ const workspace = join(homedir(), '.myvillage', 'agents', name, 'workspace');
733
+ console.log(brand.teal(` Workspace: ${workspace}`));
734
+ }
699
735
  if (tool === 'github') {
700
736
  console.log(brand.teal(' Note: Set GITHUB_TOKEN in your environment for GitHub access.'));
701
737
  }
@@ -1,8 +1,13 @@
1
- import { mkdirSync, writeFileSync } from 'fs';
1
+ import { mkdirSync, writeFileSync, existsSync } from 'fs';
2
2
  import { join } from 'path';
3
+ import { homedir } from 'os';
3
4
  import { stringify as stringifyYaml } from 'yaml';
4
5
 
5
6
  // ── MCP Tool Catalog ────────────────────────────────────
7
+ //
8
+ // Static-shape entries. The `filesystem` entry's allow-listed root is
9
+ // resolved per-agent at scaffold/add-tool time — see
10
+ // resolveCatalogEntry() — so each agent gets its own sandboxed workspace.
6
11
 
7
12
  const TOOL_CATALOG = {
8
13
  myvillage: {
@@ -12,8 +17,10 @@ const TOOL_CATALOG = {
12
17
  },
13
18
  filesystem: {
14
19
  command: 'npx',
15
- args: ['-y', '@modelcontextprotocol/server-filesystem', '~/projects'],
16
- description: 'Read/write local project files',
20
+ // Last arg is a placeholder; replaced with `<agentDir>/workspace`
21
+ // by resolveCatalogEntry().
22
+ args: ['-y', '@modelcontextprotocol/server-filesystem', '__AGENT_WORKSPACE__'],
23
+ description: 'Read/write files under the agent\'s sandboxed workspace',
17
24
  },
18
25
  gmail: {
19
26
  command: 'npx',
@@ -31,15 +38,41 @@ const TOOL_CATALOG = {
31
38
  env: { GITHUB_TOKEN: '${GITHUB_TOKEN}' },
32
39
  description: 'GitHub repository access',
33
40
  },
34
- browser: {
35
- command: 'npx',
36
- args: ['-y', '@anthropic/mcp-puppeteer'],
37
- description: 'Navigate web pages, extract content',
38
- },
41
+ // browser: removed in v1.27.0 — the previously catalog'd
42
+ // `@anthropic/mcp-puppeteer` package is no longer published under that
43
+ // name. Add a working browser MCP back when one stabilizes.
39
44
  };
40
45
 
41
46
  export { TOOL_CATALOG };
42
47
 
48
+ // ── Catalog Resolution ──────────────────────────────────
49
+
50
+ /**
51
+ * Returns a deep-copied catalog entry with per-agent placeholders
52
+ * resolved (e.g. the filesystem workspace path). Also creates any
53
+ * directories the entry depends on (the workspace dir for filesystem).
54
+ *
55
+ * Use this anywhere you'd otherwise do `{ ...TOOL_CATALOG[toolId] }`
56
+ * to write into a tools.yaml.
57
+ */
58
+ export function resolveCatalogEntry(toolId, agentName) {
59
+ const entry = TOOL_CATALOG[toolId];
60
+ if (!entry) return null;
61
+ const resolved = JSON.parse(JSON.stringify(entry));
62
+
63
+ if (toolId === 'filesystem') {
64
+ const workspace = join(homedir(), '.myvillage', 'agents', agentName, 'workspace');
65
+ if (!existsSync(workspace)) {
66
+ mkdirSync(workspace, { recursive: true });
67
+ }
68
+ resolved.args = resolved.args.map(a =>
69
+ a === '__AGENT_WORKSPACE__' ? workspace : a,
70
+ );
71
+ }
72
+
73
+ return resolved;
74
+ }
75
+
43
76
  // ── Check-in Interval Map ───────────────────────────────
44
77
 
45
78
  const INTERVAL_MAP = {
@@ -73,7 +106,7 @@ export function scaffoldAgent(agentDir, options) {
73
106
  // Write tools.yaml
74
107
  writeFileSync(
75
108
  join(agentDir, 'tools.yaml'),
76
- generateToolsYaml(tools)
109
+ generateToolsYaml(tools, name)
77
110
  );
78
111
 
79
112
  // Write logs/.gitkeep
@@ -156,17 +189,16 @@ When you receive a TASK in your context, follow these rules:
156
189
 
157
190
  // ── Tools YAML Generation ───────────────────────────────
158
191
 
159
- function generateToolsYaml(selectedTools) {
192
+ function generateToolsYaml(selectedTools, agentName) {
160
193
  const servers = {};
161
194
 
162
195
  // myvillage is always included
163
- servers['myvillage'] = TOOL_CATALOG['myvillage'];
196
+ servers['myvillage'] = resolveCatalogEntry('myvillage', agentName);
164
197
 
165
198
  for (const toolId of selectedTools) {
166
199
  if (toolId === 'myvillage') continue;
167
- if (TOOL_CATALOG[toolId]) {
168
- servers[toolId] = { ...TOOL_CATALOG[toolId] };
169
- }
200
+ const entry = resolveCatalogEntry(toolId, agentName);
201
+ if (entry) servers[toolId] = entry;
170
202
  }
171
203
 
172
204
  return stringifyYaml({ servers }, { lineWidth: 0 });