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/commands/auth.js +4 -3
- package/commands/computer.js +9 -7
- package/commands/context-sync.js +2 -1
- package/commands/gm.js +41 -14
- package/commands/integrations.js +2 -1
- package/commands/member.js +44 -10
- package/commands/ninety-nine.js +90 -30
- package/commands/orb.js +1 -1
- package/commands/pull.js +3 -3
- package/commands/push.js +2 -2
- package/commands/skill.js +5 -0
- package/commands/soul.js +2 -1
- package/commands/stream.js +3 -2
- package/commands/strings.js +1 -1
- package/commands/unknowns.js +1 -1
- package/commands/workflow.js +1 -1
- package/commands/zen.js +4 -3
- package/lib/auto-accept-certified.js +2 -3
- package/lib/autoland.js +4 -3
- package/lib/campaign-loop.js +12 -4
- package/lib/card.js +1 -1
- package/lib/chat-log-scan.js +1 -1
- package/lib/first-minute.js +1 -0
- package/lib/gm-bet.js +113 -75
- package/lib/gm-card.js +46 -19
- package/lib/gm-phone-icon.js +80 -0
- package/lib/gm-phone-page.js +866 -116
- package/lib/gm-phone.js +15 -4
- package/lib/journal.js +1 -1
- package/lib/noninteractive.js +5 -1
- package/lib/roster-swap.js +8 -3
- package/lib/task-decision.js +1 -1
- package/lib/text-table.js +138 -12
- package/lib/zen-apply.js +15 -2
- package/lib/zen.js +37 -3
- package/package.json +1 -1
- package/scripts/det/ytnotes +9 -3
- package/utils/auth.js +69 -7
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
|
-
|
|
574
|
-
|
|
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();
|
package/lib/noninteractive.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/lib/roster-swap.js
CHANGED
|
@@ -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 ?
|
|
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 ?
|
|
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
|
-
|
|
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
|
|
package/lib/task-decision.js
CHANGED
|
@@ -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-
|
|
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
|
|
114
|
+
if (displayWidth(value) <= width) return value;
|
|
22
115
|
if (width <= 1) return ELLIPSIS.slice(0, Math.max(0, width));
|
|
23
|
-
return `${value
|
|
116
|
+
return `${sliceColumns(value, width - 1).trimEnd()}${ELLIPSIS}`;
|
|
24
117
|
}
|
|
25
118
|
|
|
26
|
-
// Word-wrap one line to a width; a word
|
|
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
|
|
125
|
+
while (displayWidth(word) > width) {
|
|
32
126
|
if (line) { out.push(line); line = ''; }
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
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
|
-
|
|
58
|
-
...cells.map((row) => Math.max(...row[index].map(
|
|
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(
|
|
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])
|
|
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
|
-
|
|
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
|
|
535
|
+
const listed = [];
|
|
518
536
|
for (const [dir, group] of folders) {
|
|
519
|
-
if (group.length >= 3)
|
|
520
|
-
else for (const p of group)
|
|
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
package/scripts/det/ytnotes
CHANGED
|
@@ -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
|
|
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
|
-
!
|
|
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
|
-
|
|
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 } =
|
|
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,
|
|
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
|
|
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 } =
|
|
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,
|
|
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,
|