atris 3.74.9 → 3.74.11

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/lib/gm-phone.js CHANGED
@@ -13,6 +13,7 @@
13
13
  const fs = require('fs');
14
14
  const path = require('path');
15
15
  const crypto = require('crypto');
16
+ const zlib = require('zlib');
16
17
 
17
18
  const CARD_LIMIT = 5;
18
19
  const CONFIRM_MS = 5000;
@@ -378,7 +379,7 @@ function appendLog(logPath, row) {
378
379
  // loadView() -> view (see loadPhoneView)
379
380
  // runStep(args) -> Promise<{ code, last_line }>, one atris command
380
381
  // writeTarget(target) -> boolean; snapshotTarget() -> restore fn
381
- // logPath, now(), page(nonce) receipts, clock, html
382
+ // logPath, now(), page(nonce, first) receipts, clock, html (first: the card on screen now)
382
383
  // makeBetDesk(log) -> optional bet desk (lib/gm-bet createBetDesk)
383
384
  function createPhoneHandler(deps) {
384
385
  const now = deps.now || Date.now;
@@ -559,7 +560,7 @@ function createPhoneHandler(deps) {
559
560
  'x-frame-options': 'DENY',
560
561
  ...extra,
561
562
  });
562
- res.end(typeof body === 'string' ? body : JSON.stringify(body));
563
+ res.end(typeof body === 'string' || Buffer.isBuffer(body) ? body : JSON.stringify(body));
563
564
  };
564
565
 
565
566
  async function handle(req, res) {
@@ -570,8 +571,18 @@ function createPhoneHandler(deps) {
570
571
  try {
571
572
  if (req.method === 'GET' && check.path === '/') {
572
573
  const nonce = crypto.randomBytes(16).toString('base64');
573
- return send(res, 200, deps.page(nonce), 'text/html; charset=utf-8', {
574
- 'content-security-policy': `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'`,
574
+ // the card on screen now goes in the page itself, so its first paint is that card;
575
+ // a desk that cannot read it right now (starting again) still serves the page, without one, and the page asks
576
+ let first = null;
577
+ try { first = bet ? bet.card() : null; } catch { first = null; }
578
+ const html = deps.page(nonce, first);
579
+ // packed when the phone takes it (every browser does): about a third of the bytes over a weak wifi
580
+ const packed = /\bgzip\b/i.test(String(req.headers['accept-encoding'] || ''));
581
+ return send(res, 200, packed ? zlib.gzipSync(html) : html, 'text/html; charset=utf-8', {
582
+ vary: 'accept-encoding',
583
+ ...(packed ? { 'content-encoding': 'gzip' } : {}),
584
+ // img-src data: only for the page's own inline icon; nothing else loads an image
585
+ 'content-security-policy': `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; img-src data:; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'`,
575
586
  });
576
587
  }
577
588
  if (req.method === 'GET' && check.path === '/state') return send(res, 200, state());
package/lib/journal.js CHANGED
@@ -53,7 +53,7 @@ function parseJournalSections(content) {
53
53
  if (line.startsWith('## ')) {
54
54
  // Save previous section
55
55
  if (currentContent.length > 0 || currentSection === '__header__') {
56
- sections[currentSection] = currentContent.join('\n');
56
+ sections[currentSection] = currentContent.concat('').join('\n');
57
57
  }
58
58
  // Start new section
59
59
  currentSection = line.substring(3).trim();
@@ -69,7 +69,11 @@ function refuseHeadlessUnless(args, { allowYes = true, allowOnce = false, usage
69
69
  const lines = [];
70
70
  if (usage) lines.push(usage);
71
71
  else lines.push('This command needs a terminal, or an explicit proceed flag.');
72
- lines.push('Pass --yes to run headless' + (allowOnce ? ', or --once for a single serve op.' : '.'));
72
+ if (allowYes) {
73
+ lines.push('Pass --yes to run headless' + (allowOnce ? ', or --once for a single serve op.' : '.'));
74
+ } else if (allowOnce) {
75
+ lines.push('Pass --once for a single serve op.');
76
+ }
73
77
  console.error(lines.join('\n'));
74
78
  return true;
75
79
  }
@@ -259,6 +259,10 @@ function swapMember(root, memberName, engineWords, modelWords, options = {}) {
259
259
 
260
260
  // The phone's engine card for one member: what it runs on now, and every
261
261
  // model this machine offers that can do the member's work, grouped by tool.
262
+ // A model's last 7 days in the owner's words: the phone's swap card shows it
263
+ // under the model, so it says finished, never the team's own word for it.
264
+ const recordWords = seen => `${seen.landed} of ${seen.runs} finished this week`;
265
+
262
266
  // The current model is on top; models with a record in the last 7 days come
263
267
  // next, most landed first; the rest keep --available's order.
264
268
  function memberEngineChoices(root, memberName, options = {}) {
@@ -284,7 +288,7 @@ function memberEngineChoices(root, memberName, options = {}) {
284
288
  model: id ? name : '',
285
289
  label: !name ? 'its own model' : id ? modelText(id) || name : name,
286
290
  current: row.engine === curEngine && sameModel(row.engine, id, curModel),
287
- record: seen ? `${seen.landed} of ${seen.runs} landed` : null,
291
+ record: seen ? recordWords(seen) : null,
288
292
  landed: seen ? seen.landed : -1,
289
293
  runs: seen ? seen.runs : 0,
290
294
  index,
@@ -313,7 +317,7 @@ function memberEngineChoices(root, memberName, options = {}) {
313
317
  const mark = (option) => {
314
318
  const pin = option.model ? pinFor(option.engine, option.model) : '';
315
319
  const seen = record(option.engine, pin);
316
- return { ...option, current: option.engine === curEngine && sameModel(option.engine, pin, curModel), record: seen ? `${seen.landed} of ${seen.runs} landed` : null };
320
+ return { ...option, current: option.engine === curEngine && sameModel(option.engine, pin, curModel), record: seen ? recordWords(seen) : null };
317
321
  };
318
322
  const list = swapList(root, { ...options, offer: { registry, tools, shortlist, shortlistFile } });
319
323
  const choices = list.options
@@ -340,7 +344,8 @@ function swapFromCard(root, memberName, engineWords, modelWords, scope, options
340
344
  const { resolveEngineForMember } = require('./member-engine');
341
345
  const after = resolveEngineForMember(member, root, options);
342
346
  const moved = after.engine && after.engine.id === out.engine && sameModel(out.engine, after.model || '', out.model);
343
- if (!moved && after.pick_source === 'team') return { ...out, line: `${out.line} ${member} keeps its own line.` };
347
+ // the owner reads this: the member has its own pick, so it stays on its own model
348
+ if (!moved && after.pick_source === 'team') return { ...out, line: `${out.line} ${member} keeps its own model.` };
344
349
  return out;
345
350
  }
346
351
 
@@ -23,7 +23,7 @@ function taskTagTokens(task) {
23
23
  const fromTags = Array.isArray(task.tags) ? task.tags : [];
24
24
  const fromTag = task.tag ? [task.tag] : [];
25
25
  const fromMeta = task.metadata && Array.isArray(task.metadata.tags) ? task.metadata.tags : [];
26
- const fromTitle = (String(task.title || '').match(/#([a-z0-9-]+)/gi) || []).map((t) => t.slice(1));
26
+ const fromTitle = (String(task.title || '').match(/#([a-z0-9_-]+)/gi) || []).map((t) => t.slice(1));
27
27
  return [...fromTag, ...fromTags, ...fromMeta, ...fromTitle]
28
28
  .map(normalizeDecisionTag)
29
29
  .filter(Boolean);
package/lib/values.js CHANGED
@@ -5,20 +5,35 @@ const path = require('node:path');
5
5
 
6
6
  const PACKAGE_ROOT = path.join(__dirname, '..');
7
7
 
8
- // The bold-lead bullets under `## values` in atris.md, each as its name and
9
- // its first sentence, so the boot can show them in every new chat instead of
10
- // leaving them deep in a long file. A workspace made before the values
11
- // shipped has no such section; the package copy stands in for it.
8
+ // The values in atris.md, each as its name and its first sentence, so the
9
+ // boot can show them in every new chat instead of leaving them deep in a long
10
+ // file. A workspace that writes its own under a `## values` heading gets
11
+ // those, whatever they are called. Otherwise the four that open the operating
12
+ // rules (acceleration, accuracy, ambition, agency) are found by name. A
13
+ // workspace with neither gets the package copy.
14
+ const VALUE_NAMES = ['acceleration', 'accuracy', 'ambition', 'agency'];
15
+ const BULLET = /^- \*\*([^*]+?)\.\*\*\s+([^.]+)\./;
16
+ const asValue = (match) => ({ name: match[1].toLowerCase(), gist: match[2][0].toLowerCase() + match[2].slice(1) });
17
+
12
18
  function extractValues(content) {
13
19
  const lines = String(content || '').split(/\r?\n/);
14
20
  const start = lines.findIndex((line) => /^## values\s*$/i.test(line));
15
- if (start === -1) return [];
16
- const values = [];
17
- for (let index = start + 1; index < lines.length && !/^##\s+/.test(lines[index]); index += 1) {
18
- const match = lines[index].match(/^- \*\*([^*]+?)\.\*\*\s+([^.]+)\./);
19
- if (match) values.push({ name: match[1].toLowerCase(), gist: match[2][0].toLowerCase() + match[2].slice(1) });
21
+ if (start !== -1) {
22
+ const values = [];
23
+ for (let index = start + 1; index < lines.length && !/^##\s+/.test(lines[index]); index += 1) {
24
+ const match = lines[index].match(BULLET);
25
+ if (match) values.push(asValue(match));
26
+ }
27
+ if (values.length) return values;
28
+ }
29
+ const found = new Map();
30
+ for (const line of lines) {
31
+ const match = line.match(BULLET);
32
+ if (!match) continue;
33
+ const value = asValue(match);
34
+ if (VALUE_NAMES.includes(value.name) && !found.has(value.name)) found.set(value.name, value);
20
35
  }
21
- return values;
36
+ return found.size === VALUE_NAMES.length ? VALUE_NAMES.map((name) => found.get(name)) : [];
22
37
  }
23
38
 
24
39
  function readValues(root) {
package/lib/zen-apply.js CHANGED
@@ -30,6 +30,7 @@ const {
30
30
  nextSaveIn,
31
31
  sideCopyLines,
32
32
  scheduledCopies,
33
+ runningCopies,
33
34
  untrackedFiles,
34
35
  ABANDONED_DAYS,
35
36
  abandonedDaysFromEnv,
@@ -126,15 +127,21 @@ function putAwaySideCopies(root, cutoff, staleCutoff, tag, teamState) {
126
127
  let prune = false;
127
128
  let scheduledCount = 0;
128
129
  let locked = 0;
130
+ let running = 0;
129
131
  const kept = [];
130
132
  const worktrees = listWorktrees(root);
131
133
  const scheduled = scheduledCopies(worktrees);
134
+ const live = runningCopies(worktrees.filter((wt) => path.resolve(wt.path) !== path.resolve(root)));
132
135
  for (const wt of worktrees) {
133
136
  if (path.resolve(wt.path) === path.resolve(root)) continue;
134
137
  if (scheduled.has(path.resolve(wt.path))) {
135
138
  scheduledCount += 1;
136
139
  continue;
137
140
  }
141
+ if (live.has(path.resolve(wt.path))) {
142
+ running += 1;
143
+ continue;
144
+ }
138
145
  if (wt.locked) {
139
146
  locked += 1;
140
147
  continue;
@@ -181,7 +188,7 @@ function putAwaySideCopies(root, cutoff, staleCutoff, tag, teamState) {
181
188
  else busy.push(wt.path);
182
189
  }
183
190
  if (prune) runGit(['worktree', 'prune'], root);
184
- return { removed, busy, inUse, kept, notesKept, scheduled: scheduledCount, locked };
191
+ return { removed, busy, inUse, kept, notesKept, scheduled: scheduledCount, locked, running };
185
192
  }
186
193
 
187
194
  function clearMergedBranches(root, upstream, main, cutoff) {
@@ -295,6 +302,19 @@ function applyNotes(root, patchFile, files) {
295
302
  return { clashed, missed };
296
303
  }
297
304
 
305
+ // The map files whose code links `atris clean` re-points on every zen run.
306
+ const MAP_FILES = new Set(['atris/MAP.md', 'atris/refs/MAP-NOTES.md']);
307
+
308
+ // A map edit that only moved `file:line` numbers is the link fixer's own work
309
+ // from an earlier run, and the workspace step redoes it, so it is not news.
310
+ function lineRefsOnly(root, from, p, mine) {
311
+ if (!MAP_FILES.has(p) || !fs.existsSync(mine)) return false;
312
+ const was = runGit(['show', `${from}:${p}`], root);
313
+ if (was.status !== 0) return false;
314
+ const bare = (text) => String(text).replace(/([\w./-]+\.[A-Za-z]+):\d+(?:-\d+)?/g, '$1:#');
315
+ return bare(was.stdout) === bare(fs.readFileSync(mine, 'utf8'));
316
+ }
317
+
298
318
  function sameBytes(a, b) {
299
319
  try {
300
320
  return fs.readFileSync(a).equals(fs.readFileSync(b));
@@ -424,7 +444,7 @@ function moveKeepingNotes(root, args, target, tag, from = 'HEAD') {
424
444
  for (const p of clashed) {
425
445
  const mine = path.join(dir, 'mine', p);
426
446
  if (isJournal(p) && joinJournal(path.join(root, p), mine)) notes.push(`joined your ${p} onto the new one`);
427
- else notes.push(`kept the new ${p}; your copy is in ${mine}`);
447
+ else if (!(fromCommit && lineRefsOnly(root, fromCommit, p, mine))) notes.push(`kept the new ${p}; your copy is in ${mine}`);
428
448
  }
429
449
  for (const p of missed) {
430
450
  const mine = path.join(dir, 'mine', p);
@@ -546,6 +566,7 @@ function applyZen({ cwd = process.cwd(), fetch = true, freshMinutes, abandonedDa
546
566
  }
547
567
  if (copies.busy.length) left.push(...sideCopyLines(copies.busy, staleDays, nextSaveIn(copies.busy, staleDays, now.getTime())));
548
568
  if (copies.scheduled) left.push(`${plural(copies.scheduled, 'side copy is', 'side copies are')} used by a scheduled job, so ${copies.scheduled === 1 ? 'it stays' : 'they stay'}`);
569
+ if (copies.running) left.push(`${plural(copies.running, 'side copy has', 'side copies have')} a program running in ${copies.running === 1 ? 'it, so it stays' : 'them, so they stay'}`);
549
570
  if (copies.locked) left.push(`${plural(copies.locked, 'side copy is', 'side copies are')} locked by ${copies.locked === 1 ? 'its owner, so it stays' : 'their owners, so they stay'}`);
550
571
  if (copies.inUse) left.push(`${plural(copies.inUse, 'side copy was', 'side copies were')} used in ${window}, so ${copies.inUse === 1 ? 'it stays' : 'they stay'}`);
551
572
 
package/lib/zen.js CHANGED
@@ -205,6 +205,39 @@ function scheduledCopies(worktrees, text = scheduleText()) {
205
205
  return used;
206
206
  }
207
207
 
208
+ // Folders that programs on this machine are running in right now, read from
209
+ // lsof once per call. A side copy holding one is in use even when its git
210
+ // history looks old: a dev server or a long eval run makes no commits.
211
+ // ATRIS_ZEN_CWDS_FILE points at a file of folders, one per line (tests).
212
+ function programFolders() {
213
+ const override = process.env.ATRIS_ZEN_CWDS_FILE;
214
+ if (override) {
215
+ try {
216
+ return fs.readFileSync(override, 'utf8').split('\n').filter(Boolean);
217
+ } catch {
218
+ return [];
219
+ }
220
+ }
221
+ const out = spawnSync('lsof', ['-nP', '-d', 'cwd', '-Fn'], { encoding: 'utf8', timeout: 5000, maxBuffer: 16 * 1024 * 1024 });
222
+ return (out.stdout || '').split('\n').filter((line) => line.startsWith('n/')).map((line) => line.slice(1));
223
+ }
224
+
225
+ function runningCopies(worktrees, folders = programFolders()) {
226
+ const used = new Set();
227
+ for (const wt of worktrees) {
228
+ const at = path.resolve(wt.path);
229
+ let real = at;
230
+ try {
231
+ real = fs.realpathSync(at);
232
+ } catch {
233
+ // Gone already; compare the path as given.
234
+ }
235
+ const inside = (f) => [at, real].some((p) => f === p || f.startsWith(p + path.sep));
236
+ if (folders.some(inside)) used.add(at);
237
+ }
238
+ return used;
239
+ }
240
+
208
241
  const FRESH_MINUTES = 30;
209
242
 
210
243
  function freshMinutesFromEnv() {
@@ -225,9 +258,31 @@ function changedFiles(checkout) {
225
258
  });
226
259
  }
227
260
 
261
+ // TODO.md is rewritten from the task database by any task command run in a
262
+ // copy (see renderTodoMarkdown in lib/task-db.js). That rewrite is not anyone
263
+ // working there, so it must not keep an idle copy looking fresh forever.
264
+ const REGENERATED_MARK = 'Regenerated from durable Atris task state';
265
+
266
+ function isRegenerated(file) {
267
+ let fd;
268
+ try {
269
+ fd = fs.openSync(file, 'r');
270
+ const buf = Buffer.alloc(512);
271
+ const n = fs.readSync(fd, buf, 0, buf.length, 0);
272
+ return buf.toString('utf8', 0, n).includes(REGENERATED_MARK);
273
+ } catch {
274
+ return false;
275
+ } finally {
276
+ if (fd !== undefined) fs.closeSync(fd);
277
+ }
278
+ }
279
+
228
280
  function lastTouched(checkout) {
229
281
  let newest = 0;
230
- for (const p of changedFiles(checkout)) newest = Math.max(newest, mtime(path.join(checkout, p)));
282
+ for (const p of changedFiles(checkout)) {
283
+ const file = path.join(checkout, p);
284
+ if (!isRegenerated(file)) newest = Math.max(newest, mtime(file));
285
+ }
231
286
  return newest;
232
287
  }
233
288
 
@@ -413,8 +468,13 @@ function checkZen({ cwd = process.cwd(), fetch = true, freshMinutes = freshMinut
413
468
  const locked = [];
414
469
  const cutoff = now - freshMinutes * 60000;
415
470
  const scheduled = scheduledCopies(others);
471
+ const running = runningCopies(others);
416
472
  for (const wt of others) {
417
473
  if (scheduled.has(path.resolve(wt.path))) continue;
474
+ if (running.has(path.resolve(wt.path))) {
475
+ inUse.push(wt);
476
+ continue;
477
+ }
418
478
  if (wt.locked) {
419
479
  locked.push(wt);
420
480
  continue;
@@ -501,6 +561,24 @@ function shortPlace(p) {
501
561
  return p === home || p.startsWith(home + path.sep) ? `~${p.slice(home.length)}` : p;
502
562
  }
503
563
 
564
+ // A place too long for a narrow pane keeps as much of its start as fits,
565
+ // then its own folder name, so the line still says where and which. A cut
566
+ // that saves only a few characters hides more than it helps, so it is skipped.
567
+ const PLACE_WIDTH = 100;
568
+ function fitPlace(text, max = PLACE_WIDTH) {
569
+ if (text.length <= max) return text;
570
+ const parts = text.split('/');
571
+ const name = parts.pop();
572
+ if (parts.length < 2) return text;
573
+ let head = parts[0];
574
+ for (const part of parts.slice(1)) {
575
+ if (`${head}/${part}/…/${name}`.length > max) break;
576
+ head = `${head}/${part}`;
577
+ }
578
+ const cut = `${head}/…/${name}`;
579
+ return text.length - cut.length >= 10 ? cut : text;
580
+ }
581
+
504
582
  // Side copies left with unsaved work: at most three named, one place per
505
583
  // line so a long path still fits a narrow pane, then when zen will take
506
584
  // them over and, when known, how long until the first one goes.
@@ -514,11 +592,27 @@ function sideCopyLines(paths, days, waitMs = null) {
514
592
  if (!folders.has(dir)) folders.set(dir, []);
515
593
  folders.get(dir).push(p);
516
594
  }
517
- const rows = [];
595
+ const listed = [];
518
596
  for (const [dir, group] of folders) {
519
- if (group.length >= 3) rows.push({ text: `${group.length} in ${shortPlace(dir)}/`, count: group.length });
520
- else for (const p of group) rows.push({ text: shortPlace(p), count: 1 });
597
+ if (group.length >= 3) listed.push({ text: `${group.length} in ${shortPlace(dir)}/`, count: group.length });
598
+ else for (const p of group) listed.push({ text: fitPlace(shortPlace(p)), count: 1, path: p });
599
+ }
600
+ // A copy made inside another listed copy goes right under it, named from
601
+ // there, so a doubly nested path does not run the width of the pane.
602
+ const parentOf = new Map();
603
+ for (const row of listed) {
604
+ const holders = listed.filter((q) => q.path && row.path && row.path.startsWith(q.path + path.sep));
605
+ if (!holders.length) continue;
606
+ const holder = holders.reduce((a, b) => (b.path.length > a.path.length ? b : a));
607
+ parentOf.set(row, holder);
608
+ row.text = ` inside it: ${path.relative(holder.path, row.path)}`;
521
609
  }
610
+ const rows = [];
611
+ const place = (row) => {
612
+ rows.push(row);
613
+ for (const child of listed) if (parentOf.get(child) === row) place(child);
614
+ };
615
+ for (const row of listed) if (!parentOf.has(row)) place(row);
522
616
  for (const row of rows.slice(0, 3)) lines.push(` ${row.text}`);
523
617
  const rest = rows.slice(3).reduce((sum, row) => sum + row.count, 0);
524
618
  if (rest) lines.push(` and ${rest} more`);
@@ -551,4 +645,5 @@ module.exports = {
551
645
  listWorktrees,
552
646
  readTeamState,
553
647
  scheduledCopies,
648
+ runningCopies,
554
649
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "3.74.9",
3
+ "version": "3.74.11",
4
4
  "description": "you say what you want in plain words. atris builds it, checks it, and shows you proof.",
5
5
  "mcpName": "io.github.atrislabs/atris",
6
6
  "main": "bin/atris.js",
package/utils/auth.js CHANGED
@@ -318,6 +318,7 @@ function setSessionProfile(profileName) {
318
318
  // No terminal session ID — fall back to global switch
319
319
  return false;
320
320
  }
321
+ assertSafeLoginWrite(sessionPath);
321
322
  fs.writeFileSync(sessionPath, JSON.stringify({
322
323
  profile: profileName,
323
324
  set_at: new Date().toISOString(),
@@ -370,8 +371,31 @@ function profileNameFromEmail(email) {
370
371
  return name || null; // Guard against emails like "@domain.com"
371
372
  }
372
373
 
374
+ function runningUnderTest() {
375
+ if (process.env.NODE_TEST_CONTEXT) return true;
376
+ if ((process.execArgv || []).includes('--test')) return true;
377
+ return /\.test\.[cm]?js$/.test(path.basename(process.argv[1] || ''));
378
+ }
379
+
380
+ function realDir(dir) {
381
+ try { return fs.realpathSync(dir); } catch { return path.resolve(dir); }
382
+ }
383
+
384
+ // A test that forgets to point HOME at a scratch dir would overwrite the
385
+ // real login (2026-10-23: a mutant test minted a fake token into
386
+ // ~/.atris/credentials.json and logged the owner out). Under a test runner,
387
+ // login files may only be written inside the OS temp dir.
388
+ function assertSafeLoginWrite(targetPath) {
389
+ if (!runningUnderTest()) return;
390
+ const target = realDir(path.dirname(targetPath));
391
+ const tmp = realDir(os.tmpdir());
392
+ if (target === tmp || target.startsWith(tmp + path.sep)) return;
393
+ throw new Error(`Refusing to write ${targetPath} during a test: set HOME to a temp dir first`);
394
+ }
395
+
373
396
  function saveProfile(name, credentials) {
374
397
  const profilePath = path.join(getProfilesDir(), `${name}.json`);
398
+ assertSafeLoginWrite(profilePath);
375
399
  fs.writeFileSync(profilePath, JSON.stringify(credentials, null, 2));
376
400
  restrictToOwner(profilePath);
377
401
  }
@@ -452,6 +476,7 @@ function saveCredentials(token, refreshToken, email, userId, provider, extras =
452
476
  }
453
477
  preserveAgentClientLogin();
454
478
  const credentialsPath = getCredentialsPath();
479
+ assertSafeLoginWrite(credentialsPath);
455
480
  const credentials = {
456
481
  ...extras,
457
482
  token,
@@ -695,7 +720,7 @@ function abortOnAuthFailure(result, printedWake = false, exitFn = (code) => proc
695
720
  return true;
696
721
  }
697
722
 
698
- async function performTokenRefresh(credentials, apiRequestJson) {
723
+ async function performTokenRefresh(credentials, apiRequestJson, { validate = true } = {}) {
699
724
  if (!credentials || !credentials.refresh_token) {
700
725
  return { ok: false, error: 'missing_refresh_token' };
701
726
  }
@@ -715,13 +740,18 @@ async function performTokenRefresh(credentials, apiRequestJson) {
715
740
  const provider = refreshed.data?.provider || credentials.provider;
716
741
  const email = refreshUser?.email || credentials.email;
717
742
  const userId = refreshUser?.id || credentials.user_id;
743
+ const refreshedCredentials = { ...credentials };
744
+ const refreshedExpiry = getTokenExpiryEpochSeconds(accessToken);
745
+ if (credentials.expires_at != null && refreshedExpiry !== null) {
746
+ refreshedCredentials.expires_at = new Date(refreshedExpiry * 1000).toISOString();
747
+ }
718
748
 
719
749
  // Refreshed tokens must land in the file they were loaded from. Writing a
720
750
  // profile's refresh to the global credentials.json leaves the profile token
721
751
  // to expire permanently (every ATRIS_PROFILE session then 401s forever).
722
752
  const persistRefreshed = () => {
723
753
  if (credentials.source_profile) {
724
- const { source_profile, source, ...rest } = { ...credentials };
754
+ const { source_profile, source, ...rest } = refreshedCredentials;
725
755
  saveProfile(credentials.source_profile, {
726
756
  ...rest,
727
757
  token: accessToken,
@@ -732,17 +762,17 @@ async function performTokenRefresh(credentials, apiRequestJson) {
732
762
  saved_at: new Date().toISOString(),
733
763
  });
734
764
  } else {
735
- saveCredentials(accessToken, newRefreshToken, email, userId, provider, credentials);
765
+ saveCredentials(accessToken, newRefreshToken, email, userId, provider, refreshedCredentials);
736
766
  }
737
767
  };
738
768
 
739
769
  persistRefreshed();
740
770
  let latestCreds = loadCredentials();
741
771
 
742
- const validation = await validateAccessToken(accessToken, apiRequestJson);
772
+ const validation = validate ? await validateAccessToken(accessToken, apiRequestJson) : null;
743
773
  let finalUser = refreshUser;
744
774
 
745
- if (validation.ok && validation.data?.valid) {
775
+ if (validation?.ok && validation.data?.valid) {
746
776
  finalUser = validation.data.user || refreshUser || null;
747
777
  const updatedEmail = finalUser?.email || latestCreds?.email || email;
748
778
  const updatedProvider = finalUser?.provider || latestCreds?.provider || provider;
@@ -755,7 +785,7 @@ async function performTokenRefresh(credentials, apiRequestJson) {
755
785
  updatedUserId !== latestCreds.user_id
756
786
  ) {
757
787
  if (credentials.source_profile) {
758
- const { source_profile, source, ...rest } = { ...credentials };
788
+ const { source_profile, source, ...rest } = refreshedCredentials;
759
789
  saveProfile(credentials.source_profile, {
760
790
  ...rest,
761
791
  token: accessToken,
@@ -766,7 +796,7 @@ async function performTokenRefresh(credentials, apiRequestJson) {
766
796
  saved_at: new Date().toISOString(),
767
797
  });
768
798
  } else {
769
- saveCredentials(accessToken, newRefreshToken, updatedEmail, updatedUserId, updatedProvider, credentials);
799
+ saveCredentials(accessToken, newRefreshToken, updatedEmail, updatedUserId, updatedProvider, refreshedCredentials);
770
800
  }
771
801
  latestCreds = loadCredentials();
772
802
  }
@@ -782,6 +812,31 @@ async function performTokenRefresh(credentials, apiRequestJson) {
782
812
  };
783
813
  }
784
814
 
815
+ // Sync only needs local expiry checks, not a validation request per file.
816
+ async function ensureFreshCredentials(apiRequestJson) {
817
+ const credentials = loadCredentials();
818
+ if (!credentials || credentials.source || agentClient.isAgentClientCredential(credentials)) return credentials;
819
+ const claims = decodeJwtClaims(credentials.token);
820
+ if (claims?.type === 'agent_access' || !credentials.refresh_token) return credentials;
821
+
822
+ const selectedExpiry = expiryTimeMs(credentials.expires_at);
823
+ const isExpired = () => (selectedExpiry !== null && selectedExpiry <= Date.now())
824
+ || (typeof claims?.exp === 'number' && claims.exp * 1000 <= Date.now());
825
+ if (!isExpired() && !shouldRefreshToken(credentials.token)) return credentials;
826
+
827
+ try {
828
+ const refreshed = await performTokenRefresh(credentials, apiRequestJson, { validate: false });
829
+ if (refreshed.ok) return refreshed.payload.credentials;
830
+ } catch {
831
+ // A still-valid token can keep syncing if proactive renewal is unavailable.
832
+ }
833
+ if (isExpired()) {
834
+ console.error('Session expired. Run: atris login');
835
+ process.exit(1);
836
+ }
837
+ return credentials;
838
+ }
839
+
785
840
  async function ensureValidCredentials(apiRequestJson, options = {}) {
786
841
  let credentials = await loadCredentials(apiRequestJson);
787
842
  if (agentClient.isAgentClientCredential(credentials)) {
@@ -798,6 +853,11 @@ async function ensureValidCredentials(apiRequestJson, options = {}) {
798
853
 
799
854
  const selectedExpiry = expiryTimeMs(credentials.expires_at);
800
855
  if (selectedExpiry !== null && selectedExpiry <= Date.now()) {
856
+ if (credentials.refresh_token) {
857
+ const refreshed = await performTokenRefresh(credentials, apiRequestJson);
858
+ if (refreshed.ok) return refreshed.payload;
859
+ return { error: 'refresh_failed', detail: refreshed.error };
860
+ }
801
861
  return { error: 'token_invalid', detail: AGENT_TOKEN_EXPIRED_DETAIL };
802
862
  }
803
863
 
@@ -937,6 +997,7 @@ async function displayAccountSummary(apiRequestJson) {
937
997
  }
938
998
 
939
999
  module.exports = {
1000
+ assertSafeLoginWrite,
940
1001
  AGENT_TOKEN_EXPIRED_DETAIL,
941
1002
  decodeJwtClaims,
942
1003
  getTokenExpiryEpochSeconds,
@@ -954,6 +1015,7 @@ module.exports = {
954
1015
  validateAccessToken,
955
1016
  refreshAccessToken,
956
1017
  performTokenRefresh,
1018
+ ensureFreshCredentials,
957
1019
  ensureValidCredentials,
958
1020
  isAuthFailure,
959
1021
  abortOnAuthFailure,