atris 3.74.8 → 3.74.10

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/text-table.js CHANGED
@@ -6,6 +6,16 @@
6
6
  // than the terminal, columns marked `clip` or `wrap` shrink (rightmost
7
7
  // first): a clip column ends in "…", a wrap column carries on underneath.
8
8
  // Every other column keeps its full width.
9
+ //
10
+ // renderBorderedTable draws the same rows as a divided box, the look of the
11
+ // orb's team table. A box wider than the terminal narrows its `wrap`
12
+ // columns, the widest first, and their words carry on underneath; when it
13
+ // still does not fit it returns null and the caller prints its plain view.
14
+ //
15
+ // Every width here is terminal columns, not string length (displayWidth):
16
+ // a wide or fullwidth character takes 2, a joiner, variation selector, or
17
+ // combining mark takes 0, and a joined emoji is one 2-column cluster. Cuts
18
+ // and wraps fall between clusters, never inside one.
9
19
 
10
20
  const GAP = ' ';
11
21
  const ELLIPSIS = '…';
@@ -16,25 +26,110 @@ function terminalWidth(width) {
16
26
  return Number.isFinite(value) && value >= 20 ? value : FALLBACK_WIDTH;
17
27
  }
18
28
 
29
+ // East Asian wide and fullwidth code points, as [first, last] ranges.
30
+ const WIDE = [
31
+ [0x1100, 0x115f], [0x231a, 0x231b], [0x2329, 0x232a], [0x2e80, 0x303e], [0x3041, 0x33ff],
32
+ [0x3400, 0x4dbf], [0x4e00, 0x9fff], [0xa000, 0xa4cf], [0xa960, 0xa97f], [0xac00, 0xd7a3],
33
+ [0xf900, 0xfaff], [0xfe10, 0xfe19], [0xfe30, 0xfe6f], [0xff00, 0xff60], [0xffe0, 0xffe6],
34
+ [0x16fe0, 0x16fe4], [0x17000, 0x18cff], [0x1aff0, 0x1b2ff], [0x1f200, 0x1f2ff],
35
+ [0x20000, 0x2fffd], [0x30000, 0x3fffd],
36
+ ];
37
+ // takes no column: combining marks, joiners and other format characters,
38
+ // variation selectors, and the Hangul vowel and final jamo that join a syllable
39
+ const ZERO = /^[\p{Mn}\p{Me}\p{Cf}\u1160-\u11ff\ud7b0-\ud7ff]$/u;
40
+ // drawn as a 2-column emoji: emoji presentation, or an emoji asked for one
41
+ // with the U+FE0F selector or a keycap
42
+ const EMOJI = /\p{Emoji_Presentation}|\p{Emoji}\ufe0f|\u20e3/u;
43
+ const SEGMENTER = typeof Intl === 'object' && typeof Intl.Segmenter === 'function'
44
+ ? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
45
+ : null;
46
+
47
+ function isWide(code) {
48
+ for (const [first, last] of WIDE) {
49
+ if (code < first) return false;
50
+ if (code <= last) return true;
51
+ }
52
+ return false;
53
+ }
54
+
55
+ // The text as grapheme clusters: what a reader sees as one character.
56
+ // Without Intl.Segmenter a zero-width code point joins the cluster before it.
57
+ function graphemes(text) {
58
+ const value = String(text);
59
+ if (SEGMENTER) return Array.from(SEGMENTER.segment(value), (part) => part.segment);
60
+ const out = [];
61
+ let joined = false;
62
+ for (const ch of value) {
63
+ if (out.length && (joined || ZERO.test(ch))) out[out.length - 1] += ch;
64
+ else out.push(ch);
65
+ joined = ch === '\u200d';
66
+ }
67
+ return out;
68
+ }
69
+
70
+ // Terminal columns for one cluster: 0, 1, or 2.
71
+ function clusterWidth(cluster) {
72
+ const code = cluster.codePointAt(0);
73
+ if (cluster.length === 1 && code >= 0x20 && code < 0x7f) return 1;
74
+ if (code < 0x20 || (code >= 0x7f && code < 0xa0)) return 0;
75
+ if (EMOJI.test(cluster)) return 2;
76
+ for (const ch of cluster) {
77
+ if (!ZERO.test(ch)) return isWide(ch.codePointAt(0)) ? 2 : 1;
78
+ }
79
+ return 0;
80
+ }
81
+
82
+ // Terminal columns the text takes.
83
+ function displayWidth(text) {
84
+ const value = String(text);
85
+ // printable ascii is one column a character
86
+ if (/^[\x20-\x7e]*$/.test(value)) return value.length;
87
+ let total = 0;
88
+ for (const cluster of graphemes(value)) total += clusterWidth(cluster);
89
+ return total;
90
+ }
91
+
92
+ // The longest start of the text that fits in that many columns, whole
93
+ // clusters only.
94
+ function sliceColumns(text, columns) {
95
+ let out = '';
96
+ let used = 0;
97
+ for (const cluster of graphemes(text)) {
98
+ const w = clusterWidth(cluster);
99
+ if (used + w > columns) break;
100
+ out += cluster;
101
+ used += w;
102
+ }
103
+ return out;
104
+ }
105
+
106
+ // Pads with spaces to that many columns.
107
+ function padColumns(text, columns) {
108
+ const value = String(text);
109
+ return value + ' '.repeat(Math.max(0, columns - displayWidth(value)));
110
+ }
111
+
19
112
  function clipText(text, width) {
20
113
  const value = String(text);
21
- if (value.length <= width) return value;
114
+ if (displayWidth(value) <= width) return value;
22
115
  if (width <= 1) return ELLIPSIS.slice(0, Math.max(0, width));
23
- return `${value.slice(0, width - 1).trimEnd()}${ELLIPSIS}`;
116
+ return `${sliceColumns(value, width - 1).trimEnd()}${ELLIPSIS}`;
24
117
  }
25
118
 
26
- // Word-wrap one line to a width; a word longer than the width is split.
119
+ // Word-wrap one line to a width in columns; a word wider than the width is
120
+ // split between clusters (a lone cluster wider than the width stands alone).
27
121
  function wrapText(text, width) {
28
122
  const out = [];
29
123
  let line = '';
30
124
  for (let word of String(text).split(' ')) {
31
- while (word.length > width) {
125
+ while (displayWidth(word) > width) {
32
126
  if (line) { out.push(line); line = ''; }
33
- out.push(word.slice(0, width));
34
- word = word.slice(width);
127
+ const head = sliceColumns(word, width) || graphemes(word)[0];
128
+ out.push(head);
129
+ word = word.slice(head.length);
35
130
  }
36
131
  const next = line ? `${line} ${word}` : word;
37
- if (next.length > width && line) { out.push(line); line = word; } else line = next;
132
+ if (displayWidth(next) > width && line) { out.push(line); line = word; } else line = next;
38
133
  }
39
134
  out.push(line);
40
135
  return out;
@@ -54,19 +149,19 @@ function renderTable(columns, rows, { width } = {}) {
54
149
  const max = terminalWidth(width);
55
150
  const cells = rows.map((row) => columns.map((_, index) => cellLines(row[index])));
56
151
  const widths = columns.map((column, index) => Math.max(
57
- String(column.header).length,
58
- ...cells.map((row) => Math.max(...row[index].map((line) => line.length))),
152
+ displayWidth(column.header),
153
+ ...cells.map((row) => Math.max(...row[index].map(displayWidth))),
59
154
  ));
60
155
  let over = widths.reduce((sum, value) => sum + value, 0) + GAP.length * (columns.length - 1) - max;
61
156
  for (let index = columns.length - 1; index >= 0 && over > 0; index -= 1) {
62
157
  if (!columns[index].clip && !columns[index].wrap) continue;
63
- const floor = Math.max(String(columns[index].header).length, columns[index].min || 8);
158
+ const floor = Math.max(displayWidth(columns[index].header), columns[index].min || 8);
64
159
  const cut = Math.min(over, Math.max(0, widths[index] - floor));
65
160
  widths[index] -= cut;
66
161
  over -= cut;
67
162
  }
68
163
  const line = (parts) => parts
69
- .map((text, index) => clipText(text, widths[index]).padEnd(index === parts.length - 1 ? 0 : widths[index]))
164
+ .map((text, index) => padColumns(clipText(text, widths[index]), index === parts.length - 1 ? 0 : widths[index]))
70
165
  .join(GAP)
71
166
  .trimEnd();
72
167
  const out = [line(columns.map((column) => column.header))];
@@ -78,4 +173,35 @@ function renderTable(columns, rows, { width } = {}) {
78
173
  return out.join('\n');
79
174
  }
80
175
 
81
- module.exports = { FALLBACK_WIDTH, clipText, renderTable, terminalWidth };
176
+ // columns: [{ header, wrap?, min? }]; rows: arrays of cells. A divided box,
177
+ // or null when it cannot fit the terminal.
178
+ function renderBorderedTable(columns, rows, { width } = {}) {
179
+ const cells = rows.map((row) => columns.map((_, index) => cellLines(row[index])));
180
+ const widths = columns.map((column, index) => Math.max(
181
+ displayWidth(column.header),
182
+ ...cells.map((row) => Math.max(...row[index].map(displayWidth))),
183
+ ));
184
+ const floor = (index) => Math.max(displayWidth(columns[index].header), columns[index].min || 8);
185
+ let over = widths.reduce((sum, value) => sum + value + 3, 1) - terminalWidth(width);
186
+ while (over > 0) {
187
+ let widest = -1;
188
+ columns.forEach((column, index) => {
189
+ if (column.wrap && widths[index] > floor(index) && (widest < 0 || widths[index] > widths[widest])) widest = index;
190
+ });
191
+ if (widest < 0) return null;
192
+ widths[widest] -= 1;
193
+ over -= 1;
194
+ }
195
+ const rule = (left, mid, right) => `${left}${widths.map((w) => '─'.repeat(w + 2)).join(mid)}${right}`;
196
+ const line = (parts) => `│ ${parts.map((text, index) => padColumns(text, widths[index])).join(' │ ')} │`;
197
+ const out = [rule('┌', '┬', '┐'), line(columns.map((column) => column.header)), rule('├', '┼', '┤')];
198
+ for (const raw of cells) {
199
+ const row = raw.map((lines, index) => (columns[index].wrap ? lines.flatMap((text) => wrapText(text, widths[index])) : lines));
200
+ const height = Math.max(...row.map((lines) => lines.length));
201
+ for (let at = 0; at < height; at += 1) out.push(line(row.map((lines) => lines[at] || '')));
202
+ }
203
+ out.push(rule('└', '┴', '┘'));
204
+ return out.join('\n');
205
+ }
206
+
207
+ module.exports = { FALLBACK_WIDTH, clipText, displayWidth, renderBorderedTable, renderTable, sliceColumns, terminalWidth, wrapText };
package/lib/zen-apply.js CHANGED
@@ -295,6 +295,19 @@ function applyNotes(root, patchFile, files) {
295
295
  return { clashed, missed };
296
296
  }
297
297
 
298
+ // The map files whose code links `atris clean` re-points on every zen run.
299
+ const MAP_FILES = new Set(['atris/MAP.md', 'atris/refs/MAP-NOTES.md']);
300
+
301
+ // A map edit that only moved `file:line` numbers is the link fixer's own work
302
+ // from an earlier run, and the workspace step redoes it, so it is not news.
303
+ function lineRefsOnly(root, from, p, mine) {
304
+ if (!MAP_FILES.has(p) || !fs.existsSync(mine)) return false;
305
+ const was = runGit(['show', `${from}:${p}`], root);
306
+ if (was.status !== 0) return false;
307
+ const bare = (text) => String(text).replace(/([\w./-]+\.[A-Za-z]+):\d+(?:-\d+)?/g, '$1:#');
308
+ return bare(was.stdout) === bare(fs.readFileSync(mine, 'utf8'));
309
+ }
310
+
298
311
  function sameBytes(a, b) {
299
312
  try {
300
313
  return fs.readFileSync(a).equals(fs.readFileSync(b));
@@ -424,7 +437,7 @@ function moveKeepingNotes(root, args, target, tag, from = 'HEAD') {
424
437
  for (const p of clashed) {
425
438
  const mine = path.join(dir, 'mine', p);
426
439
  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}`);
440
+ else if (!(fromCommit && lineRefsOnly(root, fromCommit, p, mine))) notes.push(`kept the new ${p}; your copy is in ${mine}`);
428
441
  }
429
442
  for (const p of missed) {
430
443
  const mine = path.join(dir, 'mine', p);
@@ -547,7 +560,7 @@ function applyZen({ cwd = process.cwd(), fetch = true, freshMinutes, abandonedDa
547
560
  if (copies.busy.length) left.push(...sideCopyLines(copies.busy, staleDays, nextSaveIn(copies.busy, staleDays, now.getTime())));
548
561
  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'}`);
549
562
  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
- if (copies.inUse) left.push(`${plural(copies.inUse, 'side copy was', 'side copies were')} used in ${window}`);
563
+ 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
564
 
552
565
  const stray = saveStrayEdits(root, teamState, tag, cutoff);
553
566
  if (stray.saved) {
package/lib/zen.js CHANGED
@@ -501,6 +501,24 @@ function shortPlace(p) {
501
501
  return p === home || p.startsWith(home + path.sep) ? `~${p.slice(home.length)}` : p;
502
502
  }
503
503
 
504
+ // A place too long for a narrow pane keeps as much of its start as fits,
505
+ // then its own folder name, so the line still says where and which. A cut
506
+ // that saves only a few characters hides more than it helps, so it is skipped.
507
+ const PLACE_WIDTH = 100;
508
+ function fitPlace(text, max = PLACE_WIDTH) {
509
+ if (text.length <= max) return text;
510
+ const parts = text.split('/');
511
+ const name = parts.pop();
512
+ if (parts.length < 2) return text;
513
+ let head = parts[0];
514
+ for (const part of parts.slice(1)) {
515
+ if (`${head}/${part}/…/${name}`.length > max) break;
516
+ head = `${head}/${part}`;
517
+ }
518
+ const cut = `${head}/…/${name}`;
519
+ return text.length - cut.length >= 10 ? cut : text;
520
+ }
521
+
504
522
  // Side copies left with unsaved work: at most three named, one place per
505
523
  // line so a long path still fits a narrow pane, then when zen will take
506
524
  // them over and, when known, how long until the first one goes.
@@ -514,11 +532,27 @@ function sideCopyLines(paths, days, waitMs = null) {
514
532
  if (!folders.has(dir)) folders.set(dir, []);
515
533
  folders.get(dir).push(p);
516
534
  }
517
- const rows = [];
535
+ const listed = [];
518
536
  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 });
537
+ if (group.length >= 3) listed.push({ text: `${group.length} in ${shortPlace(dir)}/`, count: group.length });
538
+ else for (const p of group) listed.push({ text: fitPlace(shortPlace(p)), count: 1, path: p });
521
539
  }
540
+ // A copy made inside another listed copy goes right under it, named from
541
+ // there, so a doubly nested path does not run the width of the pane.
542
+ const parentOf = new Map();
543
+ for (const row of listed) {
544
+ const holders = listed.filter((q) => q.path && row.path && row.path.startsWith(q.path + path.sep));
545
+ if (!holders.length) continue;
546
+ const holder = holders.reduce((a, b) => (b.path.length > a.path.length ? b : a));
547
+ parentOf.set(row, holder);
548
+ row.text = ` inside it: ${path.relative(holder.path, row.path)}`;
549
+ }
550
+ const rows = [];
551
+ const place = (row) => {
552
+ rows.push(row);
553
+ for (const child of listed) if (parentOf.get(child) === row) place(child);
554
+ };
555
+ for (const row of listed) if (!parentOf.has(row)) place(row);
522
556
  for (const row of rows.slice(0, 3)) lines.push(` ${row.text}`);
523
557
  const rest = rows.slice(3).reduce((sum, row) => sum + row.count, 0);
524
558
  if (rest) lines.push(` and ${rest} more`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "3.74.8",
3
+ "version": "3.74.10",
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",
@@ -428,10 +428,14 @@ run_haiku() {
428
428
  # The Antigravity editor ships its own `agy` (a VS Code-style launcher script).
429
429
  # It has no -p mode: it hands -p and --model to Electron and prints nothing, so
430
430
  # it is not a writer. Only the Antigravity CLI is.
431
+ # Only grep's answer counts, never head's exit. In `head | grep -q` under
432
+ # pipefail, grep quits on the match and a head that touches the pipe once
433
+ # more (uutils head on Ubuntu 26.04 does, to find the end of the file) dies
434
+ # of SIGPIPE, so a busy machine read the launcher as the CLI.
431
435
  agy_is_cli() {
432
436
  local bin
433
437
  bin=$(command -v agy 2>/dev/null) || return 1
434
- ! head -c 4096 "$bin" 2>/dev/null | grep -q 'VSCODE_IPC_HOOK_CLI'
438
+ ! grep -q 'VSCODE_IPC_HOOK_CLI' < <(head -c 4096 "$bin" 2>/dev/null)
435
439
  }
436
440
 
437
441
  # An agy that is not signed in opens a Google login and waits up to 60s, which
@@ -441,7 +445,7 @@ agy_is_cli() {
441
445
  agy_signed_in() {
442
446
  local out
443
447
  out=$(run_with_timeout 10 agy models </dev/null 2>&1) || true
444
- ! printf '%s' "$out" | grep -qi 'sign in'
448
+ ! grep -qi 'sign in' <<< "$out"
445
449
  }
446
450
 
447
451
  run_gemini() {
@@ -524,7 +528,9 @@ run_auto() {
524
528
  echo "$tried did not answer; writing the notes with $label instead." >&2
525
529
  fi
526
530
  rm -f "$WORK/yt_${ID}.kill"
527
- if out=$(run_writer "$cmd" "$model" "$effort" 2>>"$WORK/yt_${ID}.engine.err") && printf '%s' "$out" | grep -q '[[:alnum:]]'; then
531
+ # A here-string, not printf | grep -q: under pipefail printf can die of
532
+ # SIGPIPE after grep's match and turn a real answer into "did not answer".
533
+ if out=$(run_writer "$cmd" "$model" "$effort" 2>>"$WORK/yt_${ID}.engine.err") && grep -q '[[:alnum:]]' <<< "$out"; then
528
534
  printf '%s\n' "$out"
529
535
  echo "notes by $label" >&2
530
536
  return 0
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,