@myvillage/cli 1.89.1 → 1.90.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.89.1",
3
+ "version": "1.90.0",
4
4
  "description": "MyVillageOS CLI for community developers",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,350 @@
1
+ // ── Agent log shipper ────────────────────────────────────────────────────────
2
+ // Ships a rolling tail of each leased agent's logs to the control plane, so an
3
+ // agent running on this machine can be read from Studio on another one without
4
+ // SSH.
5
+ //
6
+ // Design notes worth knowing before editing:
7
+ //
8
+ // * Recent, not complete. Only the newest lines are shipped and the server
9
+ // keeps a bounded window per agent. Full history stays here on disk and is
10
+ // read with `myvillage agent logs`. This is what makes the whole thing scale
11
+ // with fleet size instead of uptime.
12
+ //
13
+ // * Batched per machine. One request carries every leased agent's delta, so
14
+ // request rate tracks machine count, not agent count.
15
+ //
16
+ // * Never on the heartbeat. Lease renewal is correctness-critical; log volume
17
+ // must not be able to slow it down or fail it.
18
+ //
19
+ // * Secrets are masked HERE, before anything leaves the box. Tool arguments
20
+ // and results do ship (they are the point), but .env values and the OAuth
21
+ // token never do.
22
+ import fs from 'fs';
23
+ import path from 'path';
24
+ import os from 'os';
25
+ import { getAgentDir } from '../utils/local-agent.js';
26
+ import { getAccessToken } from '../utils/auth.js';
27
+
28
+ const CURSOR_FILE = path.join(os.homedir(), '.myvillage', 'log-cursors.json');
29
+
30
+ /** Per-stream ship caps. Must not exceed the server's retention, or we would be
31
+ * uploading lines it is about to delete. */
32
+ const SHIP_CAP = { ACTIVITY: 300, STDERR: 100 };
33
+
34
+ /** Hard ceiling on one request across all agents; mirrors the server's cap. */
35
+ const MAX_LINES_PER_PUSH = 2000;
36
+
37
+ /** Shortest .env value we will mask. Below this you get false positives that
38
+ * black out ordinary words — a var set to "true" or a family name. */
39
+ const MIN_SECRET_LEN = 8;
40
+
41
+ const DATE_LOG_RE = /^\d{4}-\d{2}-\d{2}\.jsonl$/;
42
+
43
+ // ── Cursor persistence ───────────────────────────────────────────────────────
44
+ // Keyed `${agentProfileId}:${stream}` → { file, offset }. Persisted so a runner
45
+ // restart resumes mid-file instead of re-shipping a tail we already sent.
46
+
47
+ function readCursors() {
48
+ try {
49
+ return JSON.parse(fs.readFileSync(CURSOR_FILE, 'utf-8'));
50
+ } catch {
51
+ return {};
52
+ }
53
+ }
54
+
55
+ function writeCursors(cursors) {
56
+ try {
57
+ fs.mkdirSync(path.dirname(CURSOR_FILE), { recursive: true });
58
+ fs.writeFileSync(CURSOR_FILE, JSON.stringify(cursors, null, 2), { mode: 0o600 });
59
+ } catch {
60
+ /* best-effort: a lost cursor costs one duplicated tail, not correctness */
61
+ }
62
+ }
63
+
64
+ const key = (agentProfileId, stream) => `${agentProfileId}:${stream}`;
65
+
66
+ // ── Redaction ────────────────────────────────────────────────────────────────
67
+
68
+ /**
69
+ * Literal secrets for one agent: its own .env values plus this machine's OAuth
70
+ * token. Values only — keys stay readable, so `GITHUB_TOKEN=••••` still tells
71
+ * you which credential is in play.
72
+ */
73
+ function secretsForAgent(agentName) {
74
+ const out = [];
75
+ try {
76
+ const envPath = path.join(getAgentDir(agentName), '.env');
77
+ const text = fs.readFileSync(envPath, 'utf-8');
78
+ for (const line of text.split('\n')) {
79
+ const trimmed = line.trim();
80
+ if (!trimmed || trimmed.startsWith('#')) continue;
81
+ const eq = trimmed.indexOf('=');
82
+ if (eq <= 0) continue;
83
+ let value = trimmed.slice(eq + 1).trim();
84
+ // Strip matching surrounding quotes so we mask the value, not the quoting.
85
+ if (value.length >= 2 && /^(".*"|'.*')$/s.test(value)) value = value.slice(1, -1);
86
+ if (value.length >= MIN_SECRET_LEN) out.push(value);
87
+ }
88
+ } catch {
89
+ /* no .env, or unreadable — nothing agent-specific to mask */
90
+ }
91
+ try {
92
+ const token = getAccessToken();
93
+ if (token && token.length >= MIN_SECRET_LEN) out.push(token);
94
+ } catch {
95
+ /* not signed in */
96
+ }
97
+ // Longest first, so a value containing another is masked whole.
98
+ return out.sort((a, b) => b.length - a.length);
99
+ }
100
+
101
+ /** Shapes that look like credentials even when we have never seen the value —
102
+ * a token echoed back by a third-party API, for instance. */
103
+ const SECRET_PATTERNS = [
104
+ /\b(sk|pk|rk)-[A-Za-z0-9_-]{16,}\b/g,
105
+ /\bgh[pousr]_[A-Za-z0-9]{16,}\b/g,
106
+ /\bAKIA[0-9A-Z]{12,}\b/g,
107
+ /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g,
108
+ /\bey[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, // JWT
109
+ /\b[Bb]earer\s+[A-Za-z0-9._~+/-]{20,}=*/g,
110
+ ];
111
+
112
+ function escapeRegExp(s) {
113
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
114
+ }
115
+
116
+ /**
117
+ * Mask every known secret and credential-shaped token in a line.
118
+ *
119
+ * Runs on every shipped line, so it must be cheap and must never throw — a
120
+ * redaction bug that crashes the shipper would take log visibility down, and one
121
+ * that silently returns the input would leak. On failure we drop the line rather
122
+ * than ship it unmasked.
123
+ */
124
+ export function redactSecrets(line, secrets) {
125
+ let out = line;
126
+ for (const secret of secrets) {
127
+ if (!secret) continue;
128
+ out = out.split(secret).join('••••');
129
+ }
130
+ for (const re of SECRET_PATTERNS) out = out.replace(re, '••••');
131
+ return out;
132
+ }
133
+
134
+ // ── Tailing ──────────────────────────────────────────────────────────────────
135
+
136
+ /** The daemon writes `logs/<YYYY-MM-DD>.jsonl` and rolls over at UTC midnight.
137
+ * ISO names sort chronologically, so lexical max is the current file. */
138
+ function latestActivityFile(logsDir) {
139
+ try {
140
+ const names = fs.readdirSync(logsDir).filter((n) => DATE_LOG_RE.test(n));
141
+ if (names.length === 0) return null;
142
+ names.sort();
143
+ return names[names.length - 1];
144
+ } catch {
145
+ return null;
146
+ }
147
+ }
148
+
149
+ function sourceFileFor(agentName, stream) {
150
+ const logsDir = path.join(getAgentDir(agentName), 'logs');
151
+ if (stream === 'STDERR') {
152
+ const f = path.join(logsDir, 'daemon-stderr.log');
153
+ return fs.existsSync(f) ? { dir: logsDir, name: 'daemon-stderr.log', full: f } : null;
154
+ }
155
+ const name = latestActivityFile(logsDir);
156
+ return name ? { dir: logsDir, name, full: path.join(logsDir, name) } : null;
157
+ }
158
+
159
+ /**
160
+ * Read the lines this agent has produced since our cursor.
161
+ *
162
+ * Resets to the tail (not the top) whenever continuity is broken: a new day's
163
+ * file, a truncated file, or no cursor at all. That is the backlog cap — a
164
+ * machine that was offline for a day ships its recent tail, never a day of
165
+ * backlog, so a fleet reconnecting after an outage cannot stampede the endpoint.
166
+ */
167
+ function readNewLines(agentName, stream, cursor) {
168
+ const src = sourceFileFor(agentName, stream);
169
+ if (!src) return null;
170
+
171
+ let size;
172
+ try {
173
+ size = fs.statSync(src.full).size;
174
+ } catch {
175
+ return null;
176
+ }
177
+
178
+ const continuous =
179
+ cursor && cursor.file === src.name && typeof cursor.offset === 'number' && cursor.offset <= size;
180
+ const start = continuous ? cursor.offset : 0;
181
+ if (continuous && start === size) return null; // nothing new
182
+
183
+ let text = '';
184
+ const len = size - start;
185
+ if (len > 0) {
186
+ let fd;
187
+ try {
188
+ fd = fs.openSync(src.full, 'r');
189
+ const buf = Buffer.alloc(len);
190
+ fs.readSync(fd, buf, 0, len, start);
191
+ text = buf.toString('utf8');
192
+ } catch {
193
+ return null;
194
+ } finally {
195
+ if (fd !== undefined) {
196
+ try { fs.closeSync(fd); } catch { /* ignore */ }
197
+ }
198
+ }
199
+ }
200
+
201
+ let lines = text.split('\n').filter((l) => l.trim().length > 0);
202
+ const cap = SHIP_CAP[stream];
203
+ // On a discontinuity we may be looking at a whole file; keep only the tail.
204
+ if (lines.length > cap) lines = lines.slice(-cap);
205
+
206
+ return { lines, file: src.name, offset: size };
207
+ }
208
+
209
+ /** Pull the line's own timestamp out of the JSONL, so a backlog shipped after a
210
+ * reconnect keeps its real times instead of collapsing onto arrival. */
211
+ function timestampOf(payload) {
212
+ if (payload.charCodeAt(0) !== 123 /* { */) return null;
213
+ try {
214
+ const obj = JSON.parse(payload);
215
+ const ts = obj.ts ?? obj.timestamp ?? obj.time;
216
+ return typeof ts === 'string' ? ts : null;
217
+ } catch {
218
+ return null;
219
+ }
220
+ }
221
+
222
+ // ── Shipper ──────────────────────────────────────────────────────────────────
223
+
224
+ /**
225
+ * Ship one round for every agent this runner holds a lease on.
226
+ *
227
+ * `leases` is the runner's Map of agentProfileId → { name }. Only lease holders
228
+ * may ship, which the server enforces too — the same lease that guarantees
229
+ * single-active execution guarantees a single writer per agent's log.
230
+ *
231
+ * Bootstrap: on the first round after start we send a zero-line batch to learn
232
+ * the server's cursor. If it says this machine wrote it, we adopt the offset and
233
+ * carry on mid-file; otherwise we start from the tail. That is what keeps a
234
+ * runner restart from re-shipping (and thus duplicating) the last few hundred
235
+ * lines it had already sent.
236
+ */
237
+ export async function shipLogsOnce(ctx) {
238
+ if (!ctx.leases || ctx.leases.size === 0) return { pushed: 0 };
239
+
240
+ const cursors = readCursors();
241
+ const bootstrap = !ctx.logShipperBootstrapped;
242
+ const batches = [];
243
+ let budget = MAX_LINES_PER_PUSH;
244
+
245
+ for (const [agentProfileId, lease] of ctx.leases) {
246
+ let secrets = null;
247
+ for (const stream of ['ACTIVITY', 'STDERR']) {
248
+ if (budget <= 0) break;
249
+ const k = key(agentProfileId, stream);
250
+
251
+ // First round: ask for the cursor without shipping anything.
252
+ if (bootstrap && !cursors[k]) {
253
+ batches.push({ agentProfileId, stream, lines: [] });
254
+ continue;
255
+ }
256
+
257
+ const found = readNewLines(lease.name, stream, cursors[k]);
258
+ if (!found || found.lines.length === 0) continue;
259
+
260
+ if (secrets === null) secrets = secretsForAgent(lease.name);
261
+
262
+ const lines = [];
263
+ for (const raw of found.lines) {
264
+ if (budget <= 0) break;
265
+ let payload;
266
+ try {
267
+ payload = redactSecrets(raw, secrets);
268
+ } catch {
269
+ continue; // never ship a line we failed to mask
270
+ }
271
+ lines.push({ payload, ts: timestampOf(raw) });
272
+ budget--;
273
+ }
274
+ if (lines.length === 0) continue;
275
+
276
+ batches.push({
277
+ agentProfileId,
278
+ stream,
279
+ lines,
280
+ sourceFile: found.file,
281
+ sourceOffset: found.offset,
282
+ });
283
+ }
284
+ }
285
+
286
+ if (batches.length === 0) {
287
+ ctx.logShipperBootstrapped = true;
288
+ return { pushed: 0 };
289
+ }
290
+
291
+ const res = await ctx.cp(`/machines/${ctx.machineId}/logs`, {
292
+ method: 'POST',
293
+ body: { agents: batches },
294
+ });
295
+
296
+ // Leave cursors untouched on failure so the next round retries the same
297
+ // range. Re-sending is safe; losing lines is not.
298
+ if (!res.ok) return { pushed: 0, error: res.json?.error || res.status };
299
+
300
+ // Advance our cursors only for batches the server accepted.
301
+ const rejected = new Set(
302
+ (res.json?.data?.cursors || [])
303
+ .filter((c) => c && c.rejected)
304
+ .map((c) => key(c.agentProfileId, c.stream)),
305
+ );
306
+
307
+ let pushed = 0;
308
+ for (const b of batches) {
309
+ const k = key(b.agentProfileId, b.stream);
310
+ if (rejected.has(k)) continue;
311
+ if (b.sourceFile) {
312
+ cursors[k] = { file: b.sourceFile, offset: b.sourceOffset };
313
+ pushed += b.lines.length;
314
+ }
315
+ }
316
+
317
+ // Adopt the server's position for bootstrap batches, but only where this
318
+ // machine is the one that wrote it — another machine's byte offset means
319
+ // nothing against our filesystem.
320
+ if (bootstrap) {
321
+ for (const c of res.json?.data?.cursors || []) {
322
+ if (!c || c.rejected) continue;
323
+ const k = key(c.agentProfileId, c.stream);
324
+ if (cursors[k]) continue;
325
+ if (c.lastMachineId === ctx.machineId && c.lastFile) {
326
+ cursors[k] = { file: c.lastFile, offset: c.lastOffset ?? 0 };
327
+ }
328
+ }
329
+ ctx.logShipperBootstrapped = true;
330
+ }
331
+
332
+ writeCursors(cursors);
333
+ return { pushed };
334
+ }
335
+
336
+ /** Forget an agent's cursors — used when a lease is released, so if the agent
337
+ * comes back here later we re-establish position rather than trusting an offset
338
+ * into a file another machine has since been writing. */
339
+ export function forgetLogCursor(agentProfileId) {
340
+ const cursors = readCursors();
341
+ let changed = false;
342
+ for (const stream of ['ACTIVITY', 'STDERR']) {
343
+ const k = key(agentProfileId, stream);
344
+ if (cursors[k]) {
345
+ delete cursors[k];
346
+ changed = true;
347
+ }
348
+ }
349
+ if (changed) writeCursors(cursors);
350
+ }
@@ -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,14 +22,22 @@ 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';
29
+ import { shipLogsOnce, forgetLogCursor } from './log-shipper.js';
28
30
 
29
31
  const RUNNER_STATE_FILE = path.join(os.homedir(), '.myvillage', 'runner.json');
30
32
  const DEFAULT_HEARTBEAT_MS = 20_000;
31
33
  const LEASE_TTL_SECONDS = 90;
32
34
 
35
+ // Log shipping runs on its own clock, faster than reconcile so a remote tail
36
+ // feels live, and in its own loop so a slow or failing log push can never delay
37
+ // a heartbeat. Lease renewal rides the heartbeat, and single-active correctness
38
+ // depends on it — logs must never be able to hold it up.
39
+ const LOG_SHIP_MS = 5_000;
40
+
33
41
  function log(msg, extra) {
34
42
  const line = `[runner] ${new Date().toISOString()} ${msg}`;
35
43
  if (extra !== undefined) console.log(line, extra);
@@ -75,13 +83,29 @@ function makeClient(machineKey, machineId) {
75
83
  };
76
84
  }
77
85
 
86
+ /**
87
+ * Read the signed-in user's OAuth bearer, refreshing it first when it is stale.
88
+ *
89
+ * Every headless machine needs this. On a desktop, Studio's own API traffic
90
+ * keeps the stored token fresh as a side effect; on a Pi nothing else uses the
91
+ * OAuth credential (the runner itself authenticates with its machine API key),
92
+ * so the token on disk simply expires and stays expired. Handing that dead
93
+ * token to a registration call or to a spawned daemon is a guaranteed 401.
94
+ *
95
+ * Returns null when there is no usable credential at all.
96
+ */
97
+ async function currentUserToken() {
98
+ if (isTokenExpired()) await refreshAccessToken();
99
+ return getAccessToken();
100
+ }
101
+
78
102
  /**
79
103
  * One-shot client authenticated as the signed-in *user* (OAuth bearer). Used
80
104
  * only to register a machine and receive its minted credential.
81
105
  */
82
- function makeUserClient() {
106
+ async function makeUserClient() {
83
107
  const { networkBaseUrl } = getConfig();
84
- const token = getAccessToken();
108
+ const token = await currentUserToken();
85
109
  if (!token) return null;
86
110
  return async function cp(pathname, { method = 'GET', body } = {}) {
87
111
  const headers = { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` };
@@ -198,9 +222,13 @@ function indexLocalAgentsByProfileId() {
198
222
  return map;
199
223
  }
200
224
 
201
- function startAgent(name, config, machineId, leaseEpoch) {
202
- const token = getAccessToken();
225
+ async function startAgent(name, config, machineId, leaseEpoch) {
226
+ // Refresh before snapshotting. The daemon inherits this env and bakes the
227
+ // token into its MCP Authorization header at construction, so a stale value
228
+ // here means the agent comes up with zero tools and no model access.
229
+ const token = await currentUserToken();
203
230
  if (token) process.env.MYVILLAGE_ACCESS_TOKEN = token;
231
+ else log(`no usable sign-in on this machine — ${name} will start unauthenticated; run \`myvillage login\``);
204
232
  if (config.man?.agent_id) process.env.MYVILLAGE_AGENT_ID = config.man.agent_id;
205
233
  if (config.man?.village_agent_id) process.env.MYVILLAGE_VILLAGE_AGENT_ID = config.man.village_agent_id;
206
234
  process.env.MYVILLAGE_MACHINE_ID = machineId;
@@ -262,7 +290,7 @@ async function resolveMachineIdentity(opts) {
262
290
  return { machineId, machineKey: saved.machineKey };
263
291
  }
264
292
 
265
- const userClient = makeUserClient();
293
+ const userClient = await makeUserClient();
266
294
  if (!userClient) {
267
295
  throw new Error(
268
296
  'Not signed in. Run `myvillage login` on this machine (or pass --key with a machine:runner API key).',
@@ -370,7 +398,7 @@ async function reconcileOnce(ctx) {
370
398
  continue;
371
399
  }
372
400
 
373
- const pid = startAgent(entry.name, entry.config, ctx.machineId, epoch);
401
+ const pid = await startAgent(entry.name, entry.config, ctx.machineId, epoch);
374
402
  ctx.leases.set(a.agentProfileId, { name: entry.name, epoch });
375
403
  log(`started ${entry.name} (pid ${pid}, epoch ${epoch})`);
376
404
  }
@@ -381,6 +409,10 @@ async function reconcileOnce(ctx) {
381
409
  try { stopDaemon(lease.name); } catch { /* best-effort */ }
382
410
  await ctx.cp(`/agents/${profileId}/lease`, { method: 'POST', body: { action: 'release' } });
383
411
  ctx.leases.delete(profileId);
412
+ // Drop our log position too: if this agent is placed here again later,
413
+ // another machine will have been writing its tail in the meantime, so our
414
+ // old byte offset would be meaningless.
415
+ forgetLogCursor(profileId);
384
416
  log(`stopped + released ${lease.name} (no longer assigned here)`);
385
417
  }
386
418
  }
@@ -428,6 +460,23 @@ export async function runRunner(opts = {}) {
428
460
 
429
461
  const intervalMs = opts.intervalMs || DEFAULT_HEARTBEAT_MS;
430
462
  log(`reconcile loop every ${Math.round(intervalMs / 1000)}s (lease TTL ${LEASE_TTL_SECONDS}s)`);
463
+
464
+ // Independent loop, deliberately not awaited by the reconcile loop below.
465
+ // A log push that hangs or errors must not delay lease renewal.
466
+ const shipLoop = (async () => {
467
+ while (!controller.signal.aborted) {
468
+ await sleep(LOG_SHIP_MS, controller.signal);
469
+ if (controller.signal.aborted) break;
470
+ try {
471
+ await shipLogsOnce(ctx);
472
+ } catch (err) {
473
+ // Logs are best-effort. Cursors only advance on a confirmed write, so a
474
+ // failed round is retried next tick rather than losing lines.
475
+ log('log ship error', err?.message || err);
476
+ }
477
+ }
478
+ })();
479
+
431
480
  while (!controller.signal.aborted) {
432
481
  try {
433
482
  await reconcileOnce(ctx);
@@ -436,5 +485,6 @@ export async function runRunner(opts = {}) {
436
485
  }
437
486
  if (!controller.signal.aborted) await sleep(intervalMs, controller.signal);
438
487
  }
488
+ await shipLoop;
439
489
  log('runner stopped');
440
490
  }
@@ -46,8 +46,23 @@ export function isWithinActiveHours(schedule) {
46
46
  return currentTime >= startTime || currentTime <= endTime;
47
47
  }
48
48
 
49
+ /**
50
+ * Floor on how often an agent may loop, in minutes.
51
+ *
52
+ * These agents act on human-paced events — a knowledge submission, a activity
53
+ * completion, an email — so anything under a minute is burning tokens and log
54
+ * volume against a world that has not changed. It also protects the fleet: log
55
+ * shipping and MVT spend both scale with agents × loops-per-hour, so a single
56
+ * misconfigured 15-second agent multiplied across a large fleet is what turns a
57
+ * comfortable load into an expensive one.
58
+ *
59
+ * Not an error, deliberately. An existing agent set faster than this keeps
60
+ * running, just at the floor, rather than failing to start.
61
+ */
62
+ export const MIN_CHECK_IN_MINUTES = 1;
63
+
49
64
  export function getNextCheckInMs(schedule) {
50
65
  const interval = schedule?.check_in_interval;
51
66
  if (!interval || interval <= 0) return 0; // manual only
52
- return interval * 60 * 1000;
67
+ return Math.max(interval, MIN_CHECK_IN_MINUTES) * 60 * 1000;
53
68
  }
@@ -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
  }