@myvillage/cli 1.23.2 → 1.26.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myvillage/cli",
3
- "version": "1.23.2",
3
+ "version": "1.26.0",
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;
@@ -207,8 +207,9 @@ export async function agentLoop(agentName, { signal }) {
207
207
  type: 'llm_response',
208
208
  text: (result.text || '').slice(0, 500),
209
209
  tokensUsed: {
210
- prompt: result.usage?.promptTokens || 0,
211
- completion: result.usage?.completionTokens || 0,
210
+ prompt: result.usage?.inputTokens || 0,
211
+ completion: result.usage?.outputTokens || 0,
212
+ total: result.usage?.totalTokens || 0,
212
213
  },
213
214
  });
214
215
 
@@ -313,7 +314,7 @@ export async function agentLoop(agentName, { signal }) {
313
314
  toolErrors: taskActionAudit.toolErrors,
314
315
  note: 'Marked FAILED because the action tools did not succeed. The model\'s text may claim success but the underlying tool calls errored.',
315
316
  },
316
- tokensUsed: (result.usage?.promptTokens || 0) + (result.usage?.completionTokens || 0),
317
+ tokensUsed: result.usage?.totalTokens || 0,
317
318
  durationMs: Date.now() - loopStart,
318
319
  });
319
320
  logActivity(agentDir, {
@@ -328,7 +329,7 @@ export async function agentLoop(agentName, { signal }) {
328
329
  toolCalls: activity.toolCalls,
329
330
  toolErrors: taskActionAudit.toolErrors.length > 0 ? taskActionAudit.toolErrors : undefined,
330
331
  },
331
- tokensUsed: (result.usage?.promptTokens || 0) + (result.usage?.completionTokens || 0),
332
+ tokensUsed: result.usage?.totalTokens || 0,
332
333
  durationMs: Date.now() - loopStart,
333
334
  });
334
335
  logActivity(agentDir, { type: 'task_completed', taskId: activeTask.id });
@@ -347,7 +348,7 @@ export async function agentLoop(agentName, { signal }) {
347
348
  votesGiven: activity.votesGiven,
348
349
  toolCalls: activity.toolCalls,
349
350
  modelUsed: modelId,
350
- tokensUsed: (result.usage?.promptTokens || 0) + (result.usage?.completionTokens || 0),
351
+ tokensUsed: result.usage?.totalTokens || 0,
351
352
  durationMs: Date.now() - loopStart,
352
353
  activitySummary: { feedItemsRead, mentionsFound },
353
354
  });
@@ -33,41 +33,84 @@ 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
40
- // (`@ai-sdk/mcp`). Importing from `ai` returns undefined on v6 and
41
- // throws "createMCPClient is not a function" when called.
42
- const { experimental_createMCPClient: createMCPClient } = await import('@ai-sdk/mcp');
43
+ // (`@ai-sdk/mcp`). Walk through every plausible export shape so we
44
+ // survive ESM-vs-CJS interop quirks and future renames — and if
45
+ // none match, throw a *useful* error listing what the module
46
+ // actually exposes instead of the cryptic "X is not a function".
47
+ const mcpModule = await import('@ai-sdk/mcp');
48
+ const createMCPClient =
49
+ mcpModule.experimental_createMCPClient
50
+ ?? mcpModule.createMCPClient
51
+ ?? mcpModule.default?.experimental_createMCPClient
52
+ ?? mcpModule.default?.createMCPClient;
53
+ if (typeof createMCPClient !== 'function') {
54
+ throw new Error(
55
+ `@ai-sdk/mcp loaded but no createMCPClient export found. `
56
+ + `Module exports: [${Object.keys(mcpModule).sort().join(', ') || '(empty)'}]. `
57
+ + `If you upgraded the CLI via 'npm update', try a clean reinstall: `
58
+ + `npm uninstall -g @myvillage/cli && npm install -g @myvillage/cli`
59
+ );
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
+ ]);
43
74
  let client;
44
75
 
45
76
  if (server.url) {
46
77
  // Remote MCP server via Streamable HTTP transport
47
78
  const headers = {};
48
- if (process.env.MYVILLAGE_ACCESS_TOKEN) {
49
- headers['Authorization'] = `Bearer ${process.env.MYVILLAGE_ACCESS_TOKEN}`;
50
- }
51
- if (process.env.MYVILLAGE_AGENT_ID) {
52
- 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
+ }
53
90
  }
54
- 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({
55
98
  transport: {
56
- type: 'sse',
99
+ type: transport,
57
100
  url: server.url,
58
101
  headers,
59
102
  },
60
- });
103
+ }), `${name} (${transport} ${server.url})`);
61
104
  } else {
62
105
  // Local MCP servers via stdio transport
63
- client = await createMCPClient({
106
+ client = await withTimeout(createMCPClient({
64
107
  transport: {
65
108
  type: 'stdio',
66
109
  command: server.command,
67
110
  args: server.args || [],
68
111
  env: { ...process.env, ...resolveEnvVars(server.env || {}) },
69
112
  },
70
- });
113
+ }), `${name} (stdio ${server.command})`);
71
114
  }
72
115
 
73
116
  activeClients.push(client);
@@ -106,6 +149,15 @@ export async function cleanupMCPClients() {
106
149
 
107
150
  // ── Helpers ─────────────────────────────────────────────
108
151
 
152
+ function isMyVillageHost(urlString) {
153
+ try {
154
+ const host = new URL(urlString).hostname;
155
+ return host === 'myvillageproject.ai' || host.endsWith('.myvillageproject.ai');
156
+ } catch {
157
+ return false;
158
+ }
159
+ }
160
+
109
161
  function resolveEnvVars(envMap) {
110
162
  const resolved = {};
111
163
  for (const [key, val] of Object.entries(envMap)) {
@@ -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
  }