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/atris/atris.md +78 -17
- package/atris.md +1 -10
- package/commands/auth.js +4 -3
- package/commands/computer.js +9 -7
- package/commands/context-sync.js +2 -1
- package/commands/integrations.js +2 -1
- package/commands/member.js +3 -3
- 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 +51 -48
- package/lib/gm-card.js +46 -19
- package/lib/gm-phone-icon.js +80 -0
- package/lib/gm-phone-page.js +803 -96
- 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/values.js +25 -10
- package/lib/zen-apply.js +23 -2
- package/lib/zen.js +99 -4
- package/package.json +1 -1
- 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/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
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
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))
|
|
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
|
|
595
|
+
const listed = [];
|
|
518
596
|
for (const [dir, group] of folders) {
|
|
519
|
-
if (group.length >= 3)
|
|
520
|
-
else for (const p of group)
|
|
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
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,
|