@myvillage/cli 1.89.0 → 1.89.2

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.89.0",
3
+ "version": "1.89.2",
4
4
  "description": "MyVillageOS CLI for community developers",
5
5
  "type": "module",
6
6
  "bin": {
@@ -121,6 +121,19 @@ export async function agentLoop(agentName, { signal }) {
121
121
  // max_tokens_per_loop and the rate limiter regardless of this ceiling.
122
122
  const maxSteps = config.brain?.max_steps_per_loop || 25;
123
123
 
124
+ // Refresh BEFORE the first MCP connect. Whoever spawned us put a snapshot of
125
+ // MYVILLAGE_ACCESS_TOKEN in our env, and the MCP client bakes it into its
126
+ // Authorization header at construction with no refresh path of its own. The
127
+ // per-iteration self-heal below only runs once we're already in the loop, so
128
+ // without this the very first connect uses a possibly-dead token and comes up
129
+ // with zero tools. Doing it here covers every start path (fleet runner,
130
+ // Studio's local Start, `myvillage agent start`) in one place.
131
+ try {
132
+ if (isTokenExpired()) await refreshAccessToken();
133
+ const startupToken = getAccessToken();
134
+ if (startupToken) process.env.MYVILLAGE_ACCESS_TOKEN = startupToken;
135
+ } catch { /* fall through with whatever we were handed */ }
136
+
124
137
  // Initialize MCP tools
125
138
  let tools;
126
139
  try {
@@ -183,8 +196,20 @@ export async function agentLoop(agentName, { signal }) {
183
196
  // 401s until the daemon is restarted. Re-read credentials each loop and
184
197
  // swap the MCP client when the token changes.
185
198
  try {
199
+ // refreshAccessToken() RETURNS false on failure, it does not throw, so the
200
+ // catch below never fires for a dead refresh token. Check the return value
201
+ // or the whole failure is silent: no mcp_reconnected, no error, just an
202
+ // agent that 401s on every call for the rest of its life.
186
203
  if (isTokenExpired()) {
187
- await refreshAccessToken();
204
+ const refreshed = await refreshAccessToken();
205
+ if (!refreshed) {
206
+ logActivity(agentDir, {
207
+ type: 'error',
208
+ error:
209
+ 'Could not refresh the MyVillageOS sign-in on this machine. Tools and the model will keep ' +
210
+ 'returning 401 until you run `myvillage login` here.',
211
+ });
212
+ }
188
213
  }
189
214
  const currentToken = getAccessToken();
190
215
  if (currentToken && currentToken !== process.env.MYVILLAGE_ACCESS_TOKEN) {
@@ -22,7 +22,8 @@ import os from 'os';
22
22
  import fs from 'fs';
23
23
  import path from 'path';
24
24
  import { getConfig } from '../utils/config.js';
25
- import { getAccessToken } from '../utils/auth.js';
25
+ import { getAccessToken, isTokenExpired } from '../utils/auth.js';
26
+ import { refreshAccessToken } from '../utils/api.js';
26
27
  import { listLocalAgents, readAgentConfig, getAgentDir, writeAgentConfig } from '../utils/local-agent.js';
27
28
  import { startDaemon, stopDaemon, isDaemonRunning, getDaemonInfo } from './daemon.js';
28
29
 
@@ -75,13 +76,29 @@ function makeClient(machineKey, machineId) {
75
76
  };
76
77
  }
77
78
 
79
+ /**
80
+ * Read the signed-in user's OAuth bearer, refreshing it first when it is stale.
81
+ *
82
+ * Every headless machine needs this. On a desktop, Studio's own API traffic
83
+ * keeps the stored token fresh as a side effect; on a Pi nothing else uses the
84
+ * OAuth credential (the runner itself authenticates with its machine API key),
85
+ * so the token on disk simply expires and stays expired. Handing that dead
86
+ * token to a registration call or to a spawned daemon is a guaranteed 401.
87
+ *
88
+ * Returns null when there is no usable credential at all.
89
+ */
90
+ async function currentUserToken() {
91
+ if (isTokenExpired()) await refreshAccessToken();
92
+ return getAccessToken();
93
+ }
94
+
78
95
  /**
79
96
  * One-shot client authenticated as the signed-in *user* (OAuth bearer). Used
80
97
  * only to register a machine and receive its minted credential.
81
98
  */
82
- function makeUserClient() {
99
+ async function makeUserClient() {
83
100
  const { networkBaseUrl } = getConfig();
84
- const token = getAccessToken();
101
+ const token = await currentUserToken();
85
102
  if (!token) return null;
86
103
  return async function cp(pathname, { method = 'GET', body } = {}) {
87
104
  const headers = { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` };
@@ -198,9 +215,13 @@ function indexLocalAgentsByProfileId() {
198
215
  return map;
199
216
  }
200
217
 
201
- function startAgent(name, config, machineId, leaseEpoch) {
202
- const token = getAccessToken();
218
+ async function startAgent(name, config, machineId, leaseEpoch) {
219
+ // Refresh before snapshotting. The daemon inherits this env and bakes the
220
+ // token into its MCP Authorization header at construction, so a stale value
221
+ // here means the agent comes up with zero tools and no model access.
222
+ const token = await currentUserToken();
203
223
  if (token) process.env.MYVILLAGE_ACCESS_TOKEN = token;
224
+ else log(`no usable sign-in on this machine — ${name} will start unauthenticated; run \`myvillage login\``);
204
225
  if (config.man?.agent_id) process.env.MYVILLAGE_AGENT_ID = config.man.agent_id;
205
226
  if (config.man?.village_agent_id) process.env.MYVILLAGE_VILLAGE_AGENT_ID = config.man.village_agent_id;
206
227
  process.env.MYVILLAGE_MACHINE_ID = machineId;
@@ -262,7 +283,7 @@ async function resolveMachineIdentity(opts) {
262
283
  return { machineId, machineKey: saved.machineKey };
263
284
  }
264
285
 
265
- const userClient = makeUserClient();
286
+ const userClient = await makeUserClient();
266
287
  if (!userClient) {
267
288
  throw new Error(
268
289
  'Not signed in. Run `myvillage login` on this machine (or pass --key with a machine:runner API key).',
@@ -370,7 +391,7 @@ async function reconcileOnce(ctx) {
370
391
  continue;
371
392
  }
372
393
 
373
- const pid = startAgent(entry.name, entry.config, ctx.machineId, epoch);
394
+ const pid = await startAgent(entry.name, entry.config, ctx.machineId, epoch);
374
395
  ctx.leases.set(a.agentProfileId, { name: entry.name, epoch });
375
396
  log(`started ${entry.name} (pid ${pid}, epoch ${epoch})`);
376
397
  }
@@ -771,7 +771,29 @@ export async function agentLogsCommand(name, options = {}) {
771
771
  console.log('');
772
772
  }
773
773
 
774
+ // Calendar day of the last entry printed, so we only emit a date header when
775
+ // the day actually changes. readAgentLogs walks the per-day files newest-first
776
+ // and keeps accumulating until it hits `limit`, so one invocation routinely
777
+ // spans midnight. Without a header, yesterday's 10:21 and today's 10:21 render
778
+ // identically and there is no way to tell a live agent from a stale log.
779
+ let lastPrintedDay = null;
780
+
781
+ function printDayHeader(ts) {
782
+ const d = new Date(ts);
783
+ if (Number.isNaN(d.getTime())) return;
784
+ const day = d.toLocaleDateString(undefined, {
785
+ weekday: 'short',
786
+ year: 'numeric',
787
+ month: 'short',
788
+ day: 'numeric',
789
+ });
790
+ if (day === lastPrintedDay) return;
791
+ lastPrintedDay = day;
792
+ console.log(` ${chalk.dim(`── ${day} ──`)}`);
793
+ }
794
+
774
795
  function printLogEntry(entry) {
796
+ printDayHeader(entry.ts);
775
797
  const time = brand.teal(new Date(entry.ts).toLocaleTimeString());
776
798
  const type = entry.type || 'info';
777
799
 
package/src/utils/auth.js CHANGED
@@ -100,7 +100,15 @@ export function getAccessToken() {
100
100
 
101
101
  export function isTokenExpired() {
102
102
  const creds = loadCredentials();
103
- if (!creds?.expires_at) return false;
103
+ // Nothing stored (or nothing decryptable): not "expired", just signed out.
104
+ // Callers treat a null token as unauthenticated on their own.
105
+ if (!creds?.access_token) return false;
106
+ // Fail closed. A credential with no recorded expiry cannot be proven valid,
107
+ // so treat it as expired and let the refresh path replace it — which also
108
+ // writes a real expires_at, so this self-corrects after one call. Returning
109
+ // false here meant such a token was considered valid forever: a long-lived
110
+ // agent daemon would 401 on every request and never once try to refresh.
111
+ if (!creds.expires_at) return true;
104
112
  // Consider expired if within 60 seconds of expiry
105
113
  return Date.now() >= (creds.expires_at - 60000);
106
114
  }