@myvillage/cli 1.30.0 → 1.32.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.30.0",
3
+ "version": "1.32.0",
4
4
  "description": "MyVillageOS CLI for community developers",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,7 +12,8 @@ import { getMCPTools, cleanupMCPClients } from './mcp-client.js';
12
12
  import { gatherContext } from './context.js';
13
13
  import { isWithinActiveHours, getNextCheckInMs } from './scheduler.js';
14
14
  import { parse as parseYaml } from 'yaml';
15
- import { postAgentHeartbeat, listAgentTasks, claimAgentTask, completeAgentTask } from '../utils/api.js';
15
+ import { postAgentHeartbeat, listAgentTasks, claimAgentTask, completeAgentTask, refreshAccessToken } from '../utils/api.js';
16
+ import { getAccessToken, isTokenExpired } from '../utils/auth.js';
16
17
  import { readAgentWisdom } from '../utils/wisdom.js';
17
18
 
18
19
  export async function agentLoop(agentName, { signal }) {
@@ -115,6 +116,29 @@ export async function agentLoop(agentName, { signal }) {
115
116
  logActivity(agentDir, { type: 'loop_start', iteration });
116
117
  updateHeartbeat(agentDir);
117
118
 
119
+ // Refresh OAuth token and reconnect MCP if the access token rotated.
120
+ // Why: the daemon is started with a snapshot of MYVILLAGE_ACCESS_TOKEN in
121
+ // env (see agent-local.js). The MCP client baked that token into its
122
+ // Authorization header at construction time and has no refresh path of
123
+ // its own — so once the OAuth token expires (~1h) every MCP tool call
124
+ // 401s until the daemon is restarted. Re-read credentials each loop and
125
+ // swap the MCP client when the token changes.
126
+ try {
127
+ if (isTokenExpired()) {
128
+ await refreshAccessToken();
129
+ }
130
+ const currentToken = getAccessToken();
131
+ if (currentToken && currentToken !== process.env.MYVILLAGE_ACCESS_TOKEN) {
132
+ process.env.MYVILLAGE_ACCESS_TOKEN = currentToken;
133
+ await cleanupMCPClients();
134
+ const refreshed = await getMCPTools(agentDir, config);
135
+ tools = refreshed.tools;
136
+ logActivity(agentDir, { type: 'mcp_reconnected', reason: 'token rotated' });
137
+ }
138
+ } catch (err) {
139
+ logActivity(agentDir, { type: 'error', error: `Token refresh / MCP reconnect failed: ${err.message}` });
140
+ }
141
+
118
142
  // Activity counters for this iteration
119
143
  const activity = {
120
144
  postsCreated: 0,
@@ -335,16 +359,30 @@ Guidelines:
335
359
  // whether the action tools actually succeeded — not on the model's
336
360
  // self-report. The LLM sometimes claims "I posted!" after a tool error.
337
361
  if (activeTask && config.man?.village_agent_id) {
362
+ // Three independent failure signals:
363
+ // 1. action tools tried and all failed (original signal — still right)
364
+ // 2. any tool errored AND no action tool succeeded (e.g. an
365
+ // MCP/auth error during discovery means the LLM never got to
366
+ // call an action tool — older versions marked these COMPLETED)
367
+ // 3. the LLM explicitly declared failure in its final text
368
+ // (matches the `**Task Failed:` sentinel the model emits when
369
+ // it gives up after repeated tool errors)
370
+ const llmDeclaredFailure = /^\s*\*\*Task Failed/i.test(result.text || '');
371
+ const hasToolErrors = taskActionAudit.toolErrors.length > 0;
338
372
  const shouldFail =
339
- taskActionAudit.actionToolsCalled > 0 &&
340
- taskActionAudit.actionToolsSucceeded === 0;
373
+ (taskActionAudit.actionToolsCalled > 0 && taskActionAudit.actionToolsSucceeded === 0) ||
374
+ (hasToolErrors && taskActionAudit.actionToolsSucceeded === 0) ||
375
+ llmDeclaredFailure;
341
376
 
342
377
  try {
343
378
  if (shouldFail) {
344
379
  const firstError = taskActionAudit.toolErrors[0];
380
+ const firstLine = (result.text || '').split('\n').find(l => l.trim()) || '';
345
381
  const errorMessage = firstError
346
382
  ? `${firstError.tool} failed: ${firstError.message}`
347
- : 'Action tools called but all failed';
383
+ : llmDeclaredFailure
384
+ ? firstLine.replace(/^\*\*|\*\*$/g, '').slice(0, 300) || 'Agent declared task failed'
385
+ : 'Action tools called but all failed';
348
386
  await completeAgentTask(config.man.village_agent_id, activeTask.id, {
349
387
  errorMessage,
350
388
  output: {
@@ -393,11 +431,17 @@ Guidelines:
393
431
  durationMs: Date.now() - loopStart,
394
432
  activitySummary: { feedItemsRead, mentionsFound },
395
433
  });
396
- } catch {
434
+ } catch (err) {
435
+ if (isPausedResponse(err)) throw new AgentPausedError();
397
436
  logActivity(agentDir, { type: 'error', error: 'Failed to send server heartbeat' });
398
437
  }
399
438
  }
400
439
  } catch (err) {
440
+ if (err instanceof AgentPausedError) {
441
+ logActivity(agentDir, { type: 'agent_paused', message: err.message });
442
+ console.log('\n[MyVillage] This agent has been paused by an admin. Exiting.\n');
443
+ break;
444
+ }
401
445
  logActivity(agentDir, {
402
446
  type: 'error',
403
447
  error: err.message,
@@ -545,9 +589,23 @@ function summarizeToolResult(tr) {
545
589
  return text.slice(0, 200);
546
590
  }
547
591
 
592
+ class AgentPausedError extends Error {
593
+ constructor(message = 'Agent is paused by an admin') {
594
+ super(message);
595
+ this.name = 'AgentPausedError';
596
+ this.code = 'AGENT_PAUSED';
597
+ }
598
+ }
599
+
600
+ function isPausedResponse(err) {
601
+ return err?.response?.status === 409 && err?.response?.data?.code === 'AGENT_PAUSED';
602
+ }
603
+
548
604
  // Pull up to 5 pending tasks and claim the first one we can win the race for.
549
605
  // Returns the claimed task or null. Errors are swallowed and logged — the loop
550
- // should keep running on transient backend issues.
606
+ // should keep running on transient backend issues. A pause response (409
607
+ // AGENT_PAUSED) is the one exception: rethrown as AgentPausedError so the
608
+ // outer loop can shut down cleanly.
551
609
  async function pollAndClaim(villageAgentId, agentDir) {
552
610
  try {
553
611
  const result = await listAgentTasks(villageAgentId, { status: 'PENDING', limit: 5 });
@@ -557,12 +615,15 @@ async function pollAndClaim(villageAgentId, agentDir) {
557
615
  try {
558
616
  const claim = await claimAgentTask(villageAgentId, task.id);
559
617
  return claim.data || task;
560
- } catch {
561
- // Race lost (409) or transient — try the next task
618
+ } catch (err) {
619
+ if (isPausedResponse(err)) throw new AgentPausedError();
620
+ // Race lost (409 not-paused) or transient — try the next task
562
621
  }
563
622
  }
564
623
  return null;
565
624
  } catch (err) {
625
+ if (err instanceof AgentPausedError) throw err;
626
+ if (isPausedResponse(err)) throw new AgentPausedError();
566
627
  logActivity(agentDir, { type: 'error', error: `Task poll failed: ${err.message}` });
567
628
  return null;
568
629
  }
package/src/utils/api.js CHANGED
@@ -50,7 +50,7 @@ export function createClient(baseURL) {
50
50
  return client;
51
51
  }
52
52
 
53
- async function refreshAccessToken() {
53
+ export async function refreshAccessToken() {
54
54
  const config = getConfig();
55
55
  const creds = loadCredentials();
56
56