atris 3.63.0 → 3.64.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/atris/skills/commerce/SKILL.md +24 -0
  2. package/atris/skills/x-search/SKILL.md +3 -1
  3. package/ax +4 -3
  4. package/bin/atris.js +11 -2
  5. package/cli/atris_code.py +25 -1
  6. package/commands/auth.js +79 -10
  7. package/commands/autoland.js +11 -1
  8. package/commands/browse.js +2 -2
  9. package/commands/business.js +3 -3
  10. package/commands/buy.js +3 -3
  11. package/commands/deck.js +2 -2
  12. package/commands/engine.js +122 -13
  13. package/commands/feedback.js +2 -2
  14. package/commands/fork.js +2 -2
  15. package/commands/gm.js +2 -1
  16. package/commands/improve.js +142 -8
  17. package/commands/integrations.js +4 -3
  18. package/commands/loops.js +2 -1
  19. package/commands/member.js +47 -9
  20. package/commands/mission.js +1 -1
  21. package/commands/pack-craft.js +2 -2
  22. package/commands/publish.js +2 -2
  23. package/commands/radar.js +3 -2
  24. package/commands/router.js +2 -2
  25. package/commands/task.js +84 -2
  26. package/commands/transactions.js +3 -3
  27. package/commands/truth.js +2 -2
  28. package/commands/x-search.js +81 -10
  29. package/commands/youtube.js +2 -1
  30. package/lib/autoland.js +3 -2
  31. package/lib/ax-auto-lane.js +2 -2
  32. package/lib/brief-rules.js +11 -0
  33. package/lib/chat-log-scan.js +2 -1
  34. package/lib/ci-runner.js +3 -2
  35. package/lib/codex-flight.js +2 -1
  36. package/lib/commerce.js +17 -2
  37. package/lib/deck-history.js +2 -2
  38. package/lib/deck-review.js +3 -3
  39. package/lib/design-api.js +2 -2
  40. package/lib/engine-registry.js +85 -18
  41. package/lib/first-minute.js +2 -2
  42. package/lib/fleet.js +8 -36
  43. package/lib/git-spawn.js +5 -2
  44. package/lib/ignored-cache.js +51 -0
  45. package/lib/manifest.js +2 -2
  46. package/lib/map-refs.js +70 -4
  47. package/lib/pack-capabilities.js +2 -1
  48. package/lib/permission-grants.js +2 -2
  49. package/lib/pulse.js +5 -5
  50. package/lib/roster-runs.js +13 -2
  51. package/lib/runner-command.js +22 -6
  52. package/lib/sandbox-pids.js +113 -0
  53. package/lib/secret-gateway.js +3 -13
  54. package/lib/state-home.js +106 -0
  55. package/lib/task-db.js +4 -3
  56. package/lib/task-prune.js +462 -0
  57. package/lib/x-post-check.js +497 -0
  58. package/package.json +1 -1
  59. package/scripts/det/data/xsearch-answer.json +24 -0
  60. package/scripts/det/data/xsearch-live-2026-09-29.json +93 -0
  61. package/scripts/det/xsearch-bench.js +307 -0
  62. package/scripts/det/ytnotes +61 -15
  63. package/scripts/det/ytrail-bench.js +2 -1
  64. package/utils/auth.js +3 -3
  65. package/utils/update-check.js +2 -2
package/lib/map-refs.js CHANGED
@@ -6,8 +6,10 @@
6
6
  // A name elsewhere in the sentence never binds: map sentences often name a helper
7
7
  // the ref does not point at, and binding it would move the ref to the wrong place.
8
8
 
9
+ const crypto = require('crypto');
9
10
  const fs = require('fs');
10
11
  const path = require('path');
12
+ const ignoredCache = require('./ignored-cache');
11
13
 
12
14
  const WINDOW = 3;
13
15
  const IDENT = /^[A-Za-z_$][\w$]*$/;
@@ -123,6 +125,14 @@ function classify(ref, file) {
123
125
  }
124
126
 
125
127
  function checkMapRefs(root, mapText) {
128
+ const parsed = parseRefs(mapText);
129
+ const results = classifyRefs(root, parsed);
130
+ return summarize(parsed.map((ref, i) => ({ ...ref, ...results[i] })));
131
+ }
132
+
133
+ // What the code says about each parsed ref: its status, plus the line it moved
134
+ // to or why it is missing. Each file a ref points at is read once.
135
+ function classifyRefs(root, parsed) {
126
136
  const cache = new Map();
127
137
  const linesOf = file => {
128
138
  if (!cache.has(file)) {
@@ -144,11 +154,10 @@ function checkMapRefs(root, mapText) {
144
154
  }
145
155
  return cache.get(file);
146
156
  };
147
- const refs = parseRefs(mapText).map(ref => {
157
+ return parsed.map(ref => {
148
158
  const file = linesOf(ref.path);
149
- return { ...ref, ...(file ? classify(ref, file) : { status: 'missing-file' }) };
159
+ return file ? classify(ref, file) : { status: 'missing-file' };
150
160
  });
151
- return summarize(refs);
152
161
  }
153
162
 
154
163
  function summarize(refs) {
@@ -197,13 +206,70 @@ function existingMapDocs(root) {
197
206
  });
198
207
  }
199
208
 
209
+ // Boot scores the docs every session, and checking every ref means reading
210
+ // every file the map points at (megabytes on this repo). The result for each
211
+ // map file is saved in the ignored .atris/cache and reused only while the map
212
+ // text and the stat stamp of every file it points at are unchanged.
213
+ const REFS_CACHE_NAME = 'map-refs.json';
214
+
215
+ // Size, modified time, change time, and inode of a file a ref points at; null
216
+ // when it is not there. Change time moves on every write and cannot be set
217
+ // back by hand, so an edit that keeps size and modified time still misses.
218
+ function fileStamp(full) {
219
+ try {
220
+ const stat = fs.statSync(full);
221
+ return `${stat.isFile() ? 'f' : 'o'}:${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}:${stat.ino}`;
222
+ } catch (error) {
223
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return null;
224
+ throw error;
225
+ }
226
+ }
227
+
228
+ function refsCacheKey(root, text, parsed) {
229
+ const files = [...new Set(parsed.map(ref => ref.path))];
230
+ const stamps = files.map(file => [file, fileStamp(path.resolve(root, file))]);
231
+ return crypto.createHash('sha1').update(JSON.stringify([text, stamps])).digest('hex');
232
+ }
233
+
234
+ function loadRefsCache(root) {
235
+ const cache = { root, docs: {}, dirty: false, writable: ignoredCache.cacheEnabled(root) };
236
+ if (!cache.writable) return cache;
237
+ const parsed = ignoredCache.readCacheJson(root, REFS_CACHE_NAME);
238
+ if (parsed && parsed.version === 1 && parsed.docs && typeof parsed.docs === 'object') cache.docs = parsed.docs;
239
+ return cache;
240
+ }
241
+
242
+ function saveRefsCache(cache) {
243
+ if (!cache.writable || !cache.dirty) return;
244
+ ignoredCache.writeCacheJson(cache.root, REFS_CACHE_NAME, { version: 1, docs: cache.docs });
245
+ }
246
+
247
+ function cachedMapRefs(root, doc, text, cache) {
248
+ const parsed = parseRefs(text);
249
+ const key = cache.writable ? refsCacheKey(root, text, parsed) : '';
250
+ const hit = key && cache.docs[doc];
251
+ let results;
252
+ if (hit && hit.key === key && Array.isArray(hit.results) && hit.results.length === parsed.length) {
253
+ results = hit.results;
254
+ } else {
255
+ results = classifyRefs(root, parsed);
256
+ if (key) {
257
+ cache.docs[doc] = { key, results };
258
+ cache.dirty = true;
259
+ }
260
+ }
261
+ return parsed.map((ref, i) => ({ ...ref, ...results[i] }));
262
+ }
263
+
200
264
  // mapText is the already-read atris/MAP.md, so boot does not read it twice.
201
265
  function checkMapDocs(root, mapText) {
266
+ const cache = loadRefsCache(root);
202
267
  const refs = existingMapDocs(root).flatMap(doc => {
203
268
  const text = doc === MAP_REF_DOCS[0] && typeof mapText === 'string'
204
269
  ? mapText : fs.readFileSync(path.resolve(root, doc), 'utf8');
205
- return checkMapRefs(root, text).refs.map(ref => ({ doc, ...ref }));
270
+ return cachedMapRefs(root, doc, text, cache).map(ref => ({ doc, ...ref }));
206
271
  });
272
+ saveRefsCache(cache);
207
273
  return summarize(refs);
208
274
  }
209
275
 
@@ -3,6 +3,7 @@
3
3
  const fs = require('fs');
4
4
  const os = require('os');
5
5
  const path = require('path');
6
+ const { stateHome } = require('./state-home');
6
7
  const dns = require('dns').promises;
7
8
  const net = require('net');
8
9
  const { randomUUID, createHash } = require('crypto');
@@ -250,7 +251,7 @@ function buildClaudeCapabilityArgs(policy, options = {}) {
250
251
  function receiptDirectory(options = {}) {
251
252
  if (options.receiptDir) return path.resolve(options.receiptDir);
252
253
  if (process.env.ATRIS_PACK_RUNS_DIR) return path.resolve(process.env.ATRIS_PACK_RUNS_DIR);
253
- return path.join(os.homedir(), '.atris', 'runs', 'packs');
254
+ return path.join(stateHome(), '.atris', 'runs', 'packs');
254
255
  }
255
256
 
256
257
  function appendReceiptEvent(eventsPath, event) {
@@ -8,8 +8,8 @@
8
8
  // Design brief: atris/designs/permission-grants-cli-831.md
9
9
 
10
10
  const fs = require('fs');
11
- const os = require('os');
12
11
  const path = require('path');
12
+ const { stateHome } = require('./state-home');
13
13
  const crypto = require('crypto');
14
14
 
15
15
  const GRANTS_SCHEMA = 'atris.permission_grants.v1';
@@ -39,7 +39,7 @@ const NEVER_GRANTABLE = [
39
39
 
40
40
  function grantsFilePath() {
41
41
  return process.env.ATRIS_PERMISSION_GRANTS_FILE
42
- || path.join(os.homedir(), '.atris', 'permission-grants.json');
42
+ || path.join(stateHome(), '.atris', 'permission-grants.json');
43
43
  }
44
44
 
45
45
  function parseArgv(command) {
package/lib/pulse.js CHANGED
@@ -14,8 +14,8 @@
14
14
 
15
15
  const fs = require('fs');
16
16
  const crypto = require('crypto');
17
- const os = require('os');
18
17
  const path = require('path');
18
+ const { stateHome: userStateHome } = require('./state-home');
19
19
  const { DEFAULT_CLAUDE_RUNNER_MODEL } = require('./runner-command');
20
20
  const { readOrbScorecard } = require('./orb-scorecard');
21
21
 
@@ -69,7 +69,7 @@ function pulseRepoSlug(root) {
69
69
  return `${base}-${hash}`;
70
70
  }
71
71
 
72
- function pulseStateHome(root, homeDir = os.homedir()) {
72
+ function pulseStateHome(root, homeDir = userStateHome()) {
73
73
  return path.join(homeDir, '.atris', 'overnight', `pulse-${pulseRepoSlug(root)}`);
74
74
  }
75
75
 
@@ -77,7 +77,7 @@ function pulseMarker(root) {
77
77
  return `ATRIS_PULSE_${pulseRepoSlug(root).toUpperCase().replace(/[^A-Z0-9]+/g, '_')}`;
78
78
  }
79
79
 
80
- function legacyPulseStateHome(homeDir = os.homedir()) {
80
+ function legacyPulseStateHome(homeDir = userStateHome()) {
81
81
  return path.join(homeDir, '.atris', 'overnight', LEGACY_STATE_DIRNAME);
82
82
  }
83
83
 
@@ -92,7 +92,7 @@ function readTickScriptRoot(stateHome) {
92
92
  }
93
93
  }
94
94
 
95
- function legacyPulseStateMatchesRoot(root, homeDir = os.homedir()) {
95
+ function legacyPulseStateMatchesRoot(root, homeDir = userStateHome()) {
96
96
  const legacyStateHome = legacyPulseStateHome(homeDir);
97
97
  const scriptRoot = readTickScriptRoot(legacyStateHome);
98
98
  if (!scriptRoot) return false;
@@ -100,7 +100,7 @@ function legacyPulseStateMatchesRoot(root, homeDir = os.homedir()) {
100
100
  }
101
101
 
102
102
  function resolvePulseSlot(root, options = {}) {
103
- const homeDir = options.homeDir || os.homedir();
103
+ const homeDir = options.homeDir || userStateHome();
104
104
  const stateHome = pulseStateHome(root, homeDir);
105
105
  const marker = pulseMarker(root);
106
106
  const legacyStateHome = legacyPulseStateHome(homeDir);
@@ -29,14 +29,25 @@ const TAIL_BYTES = 512 * 1024;
29
29
  const RUNS_ROTATE_BYTES = 2 * 1024 * 1024;
30
30
  const JOB_ALIASES = Object.freeze({ navigator: 'search', executor: 'build', builder: 'build', validator: 'review', reviewer: 'review' });
31
31
 
32
+ // The record belongs to the project, not to the scratch checkout a run
33
+ // happened in. A fleet build, a mission, or an ask run inside a git worktree
34
+ // writes to the main checkout, so the history survives when the worktree is
35
+ // reaped and `atris engine roster --runs` reads the same file from either
36
+ // place. Same pointer-file lookup the roster and member cards use.
37
+ function runsRoot(root = process.cwd()) {
38
+ const { mainCheckoutRoot } = require('./engine-registry');
39
+ const main = mainCheckoutRoot(root);
40
+ return main && fs.existsSync(main) ? main : root;
41
+ }
42
+
32
43
  function rosterRunsPath(root = process.cwd()) {
33
- return path.join(root, RUNS_FILE);
44
+ return path.join(runsRoot(root), RUNS_FILE);
34
45
  }
35
46
 
36
47
  // Rotated logs, oldest name first; the stamp in each name makes name order
37
48
  // the order they were renamed in.
38
49
  function rotatedRunsPaths(root = process.cwd()) {
39
- const dir = path.dirname(path.join(root, RUNS_FILE));
50
+ const dir = path.dirname(rosterRunsPath(root));
40
51
  let names;
41
52
  try {
42
53
  names = fs.readdirSync(dir);
@@ -87,9 +87,11 @@ const RUNNER_PROFILE_DEFS = Object.freeze({
87
87
  }),
88
88
  // No hard pin: a pinned grok-4.6 went stale when the grok CLI moved its
89
89
  // own default to grok-4.7-build-fast, so an unpinned run rides the CLI.
90
+ // Grok runs only xAI models, so a claude model name never reaches it.
90
91
  grok: Object.freeze({
91
92
  bin: 'grok',
92
93
  model: '',
94
+ ownModelsOnly: true,
93
95
  commandTemplate: '{bin} --always-approve {pinnedModelFlag} {pinnedEffortFlag} -p {prompt}',
94
96
  effortFlag: '--reasoning-effort',
95
97
  efforts: Object.freeze(['low', 'medium', 'high']),
@@ -213,6 +215,17 @@ function resolvePinnedRunnerModel(mission) {
213
215
  return explicit || runnerProfileValue('model');
214
216
  }
215
217
 
218
+ // The own-models-only engine a runner bin launches ("codex", "/opt/bin/grok"),
219
+ // or '' when the program is not one of them.
220
+ function ownModelsOnlyEngineForBin(bin) {
221
+ const name = String(bin || '').split(/[\\/]/).pop().replace(/\.exe$/i, '').toLowerCase();
222
+ if (!name) return '';
223
+ return Object.keys(RUNNER_PROFILE_DEFS).find((id) => {
224
+ const def = RUNNER_PROFILE_DEFS[id];
225
+ return def.ownModelsOnly && def.bin === name;
226
+ }) || '';
227
+ }
228
+
216
229
  // The model an engine is launched with. An engine that runs only its own
217
230
  // vendor's models drops a claude model name (a mission's stored default, a
218
231
  // claude env pin) and rides its own default instead.
@@ -321,12 +334,13 @@ function buildRunnerCommand({ promptFile, allowedTools, model, effort } = {}) {
321
334
  }
322
335
  const resolved = resolveClaudeRunnerModel({ model });
323
336
  const template = resolveClaudeRunnerCommandTemplate();
337
+ // Both the profile and the program that actually runs count: a runner bin
338
+ // named codex or grok (or a path ending in one) is that engine under any
339
+ // profile, with or without a template.
340
+ const profile = resolveRunnerProfileName();
341
+ const binEngine = ownModelsOnlyEngineForBin(resolveClaudeRunnerBin());
342
+ const modelFor = (value) => runnerModelFor(binEngine, runnerModelFor(profile, value));
324
343
  if (template) {
325
- // Both the profile and the program that actually runs count: a runner
326
- // bin named codex (or a path ending in it) is codex under any profile.
327
- const profile = resolveRunnerProfileName();
328
- const binName = String(resolveClaudeRunnerBin()).split(/[\\/]/).pop();
329
- const modelFor = (value) => runnerModelFor(binName, runnerModelFor(profile, value));
330
344
  return renderRunnerCommandTemplate(template, {
331
345
  promptFile,
332
346
  allowedTools,
@@ -336,7 +350,9 @@ function buildRunnerCommand({ promptFile, allowedTools, model, effort } = {}) {
336
350
  });
337
351
  }
338
352
  const safePath = String(promptFile).replace(/'/g, "'\\''");
339
- let cmd = `${shellWord(resolveClaudeRunnerBin())} -p "$(cat '${safePath}')" --model ${shellWord(resolved)}`;
353
+ const launchModel = modelFor(resolved);
354
+ let cmd = `${shellWord(resolveClaudeRunnerBin())} -p "$(cat '${safePath}')"`;
355
+ if (launchModel) cmd += ` --model ${shellWord(launchModel)}`;
340
356
  const effortFlag = runnerEffortFlag(effort);
341
357
  if (effortFlag) cmd += ` ${effortFlag}`;
342
358
  if (allowedTools) {
@@ -0,0 +1,113 @@
1
+ 'use strict';
2
+
3
+ // Find every process a sealed run may have left behind: members of its
4
+ // process group, holders of its lease file, and anything whose cwd sits in its
5
+ // worktree. Each lsof scan walks the whole process table (about 300 to 500ms on
6
+ // a busy mac), and the reaper repeats the sweep six times per sealed run, so
7
+ // the three scans run side by side instead of one after another. They run from
8
+ // "/" so a scan never finds its own siblings through the worktree cwd.
9
+
10
+ const { spawn, spawnSync } = require('node:child_process');
11
+
12
+ function sandboxPidQueries({ pgid, leaseFile, cwd }) {
13
+ const queries = [];
14
+ const group = Number(pgid);
15
+ if (Number.isInteger(group) && group > 0) queries.push(['/usr/bin/pgrep', ['-g', String(group)]]);
16
+ queries.push(['/usr/sbin/lsof', ['-t', String(leaseFile)]]);
17
+ if (cwd) queries.push(['/usr/sbin/lsof', ['-a', '-d', 'cwd', '+D', String(cwd), '-t']]);
18
+ return queries;
19
+ }
20
+
21
+ function collectPids(outputs, excluded) {
22
+ const pids = new Set();
23
+ for (const output of outputs) {
24
+ for (const value of String(output || '').trim().split(/\s+/)) {
25
+ const pid = Number(value);
26
+ if (Number.isInteger(pid) && pid > 0) pids.add(pid);
27
+ }
28
+ }
29
+ for (const pid of excluded) pids.delete(pid);
30
+ return [...pids];
31
+ }
32
+
33
+ // pgrep and lsof both exit 1 when nothing matches. Anything else (another
34
+ // status, a signal, a spawn error, no stdout pipe) means the scan did not run,
35
+ // which is not the same as finding nothing.
36
+ function runScan(bin, argv) {
37
+ return new Promise((resolve) => {
38
+ let stdout = '';
39
+ let settled = false;
40
+ const finish = (ok) => {
41
+ if (settled) return;
42
+ settled = true;
43
+ resolve({ ok, stdout: ok ? stdout : '' });
44
+ };
45
+ let child;
46
+ try {
47
+ child = spawn(bin, argv, { cwd: '/', stdio: ['ignore', 'pipe', 'ignore'] });
48
+ } catch {
49
+ finish(false);
50
+ return;
51
+ }
52
+ child.once('error', () => finish(false));
53
+ if (!child.stdout) {
54
+ finish(false);
55
+ return;
56
+ }
57
+ child.stdout.on('error', () => finish(false));
58
+ child.stdout.setEncoding('utf8');
59
+ child.stdout.on('data', (chunk) => { stdout += chunk; });
60
+ child.once('close', (status, signal) => finish(!signal && (status === 0 || status === 1)));
61
+ });
62
+ }
63
+
64
+ function sequentialOutputs(query) {
65
+ return sandboxPidQueries(query).map(([bin, argv]) => {
66
+ try {
67
+ return spawnSync(bin, argv, { cwd: '/', encoding: 'utf8' }).stdout;
68
+ } catch {
69
+ return '';
70
+ }
71
+ });
72
+ }
73
+
74
+ // If any side-by-side scan fails, run the whole sweep again one by one and
75
+ // keep every pid either pass saw.
76
+ async function trackedSandboxPids(query, { excluded = [process.pid], scan = runScan } = {}) {
77
+ const scans = await Promise.all(sandboxPidQueries(query).map(([bin, argv]) => scan(bin, argv)));
78
+ const outputs = scans.map((result) => result.stdout);
79
+ if (scans.some((result) => !result.ok)) outputs.push(...sequentialOutputs(query));
80
+ return collectPids(outputs, excluded);
81
+ }
82
+
83
+ // Synchronous callers get the same parallel sweep through one short node
84
+ // helper. If the helper cannot answer, fall back to the one-by-one sweep so
85
+ // the reaper never runs blind.
86
+ function trackedSandboxPidsSync(query) {
87
+ const excluded = [process.pid];
88
+ const helper = spawnSync(process.execPath, [__filename, JSON.stringify(query)], {
89
+ cwd: '/',
90
+ encoding: 'utf8',
91
+ });
92
+ if (helper.status === 0) {
93
+ try {
94
+ const parsed = JSON.parse(helper.stdout);
95
+ if (Array.isArray(parsed)) {
96
+ return collectPids([parsed.join(' ')], [...excluded, helper.pid]);
97
+ }
98
+ } catch {}
99
+ }
100
+ return collectPids(sequentialOutputs(query), excluded);
101
+ }
102
+
103
+ if (require.main === module) {
104
+ trackedSandboxPids(JSON.parse(process.argv[2] || '{}'))
105
+ .then((pids) => { process.stdout.write(JSON.stringify(pids) + '\n'); })
106
+ .catch(() => { process.exitCode = 1; });
107
+ }
108
+
109
+ module.exports = {
110
+ runScan,
111
+ trackedSandboxPids,
112
+ trackedSandboxPidsSync,
113
+ };
@@ -393,6 +393,7 @@ function buildGatewaySupervisorScript(gatewayModulePath) {
393
393
  "const fs = require('node:fs');",
394
394
  "const { spawn, spawnSync } = require('node:child_process');",
395
395
  `const sg = require(${JSON.stringify(gatewayModulePath)});`,
396
+ `const sandboxPids = require(${JSON.stringify(require.resolve('./sandbox-pids'))});`,
396
397
  "const [exitFile, stateFile, leaseFile, statusFile, executable, ...args] = process.argv.slice(2);",
397
398
  "const leaseFd = fs.openSync(leaseFile, 'w', 0o600);",
398
399
  "const statusFd = fs.openSync(statusFile, 'w', 0o600);",
@@ -413,18 +414,7 @@ function buildGatewaySupervisorScript(gatewayModulePath) {
413
414
  " fs.writeFileSync(stateFile, JSON.stringify({ pgid: child.pid, cwd: process.cwd() }) + '\\n', { mode: 0o600 });",
414
415
  ' let stopping = false;',
415
416
  ' const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));',
416
- ' function trackedPids() {',
417
- ' const pids = new Set();',
418
- " for (const [bin, argv] of [['/usr/bin/pgrep', ['-g', String(child.pid)]], ['/usr/sbin/lsof', ['-t', leaseFile]], ['/usr/sbin/lsof', ['-a', '-d', 'cwd', '+D', process.cwd(), '-t']]]) {",
419
- " const found = spawnSync(bin, argv, { encoding: 'utf8' });",
420
- " for (const value of String(found.stdout || '').trim().split(/\\s+/)) {",
421
- ' const pid = Number(value);',
422
- ' if (Number.isInteger(pid) && pid > 0) pids.add(pid);',
423
- ' }',
424
- ' }',
425
- ' pids.delete(process.pid);',
426
- ' return [...pids];',
427
- ' }',
417
+ ' const trackedPids = () => sandboxPids.trackedSandboxPids({ pgid: child.pid, leaseFile, cwd: process.cwd() });',
428
418
  ' async function stop(code) {',
429
419
  ' if (stopping) return;',
430
420
  ' stopping = true;',
@@ -433,7 +423,7 @@ function buildGatewaySupervisorScript(gatewayModulePath) {
433
423
  ' }',
434
424
  " for (const [signal, delay] of [['SIGTERM', 100], ['SIGKILL', 100], ['SIGKILL', 100]]) {",
435
425
  ' try { process.kill(-child.pid, signal); } catch {}',
436
- ' for (const pid of trackedPids()) { try { process.kill(pid, signal); } catch {} }',
426
+ ' for (const pid of await trackedPids()) { try { process.kill(pid, signal); } catch {} }',
437
427
  ' await wait(delay);',
438
428
  ' }',
439
429
  ' try { await gateway.close(); } catch {}',
@@ -0,0 +1,106 @@
1
+ // Where ~/.atris state lives, with a fence for test runs.
2
+ //
3
+ // The node test runner sets NODE_TEST_CONTEXT in every test-file process, and
4
+ // CLI subprocesses spawned by tests inherit it. A test that forgets to point
5
+ // ATRIS_TASKS_DB or HOME at a temp dir used to write the user's real task
6
+ // database. Under the test runner, when HOME is still the real user home, we
7
+ // hand back a throwaway home instead, so forgetting the override is safe.
8
+ //
9
+ // Explicit choices still win: a temp HOME keeps its own .atris, and
10
+ // ATRIS_TEST_REAL_HOME=1 opts a test back into the real home on purpose.
11
+ // Outside the test runner nothing changes.
12
+ const fs = require('fs');
13
+ const os = require('os');
14
+ const path = require('path');
15
+
16
+ function normalizedPath(p) {
17
+ let resolved;
18
+ try { resolved = fs.realpathSync(p); } catch (_) { resolved = path.resolve(p); }
19
+ const foldCase = process.platform === 'darwin' || process.platform === 'win32';
20
+ return foldCase ? resolved.toLowerCase() : resolved;
21
+ }
22
+
23
+ // Same folder under any spelling: symlinks, letter case, and macOS firmlinks
24
+ // (the home folder seen through /System/Volumes/Data), which realpath leaves
25
+ // alone but which share the device and inode of the usual path.
26
+ function samePath(a, b) {
27
+ if (!a || !b) return false;
28
+ if (normalizedPath(a) === normalizedPath(b)) return true;
29
+ try {
30
+ const sa = fs.statSync(a);
31
+ const sb = fs.statSync(b);
32
+ return sa.ino !== 0 && sa.dev === sb.dev && sa.ino === sb.ino;
33
+ } catch (_) {
34
+ return false;
35
+ }
36
+ }
37
+
38
+ // The account's home from the user database, not HOME. Read once per
39
+ // process: it cannot change while we run, and callers resolve paths often.
40
+ let realUserHome;
41
+ function realHomeOnce() {
42
+ if (realUserHome === undefined) {
43
+ try { realUserHome = os.userInfo().homedir || null; } catch (_) { realUserHome = null; }
44
+ }
45
+ return realUserHome;
46
+ }
47
+
48
+ function protectedHomes(env) {
49
+ const homes = [];
50
+ if (realHomeOnce()) homes.push(realHomeOnce());
51
+ // Test seam that can only widen the fence: marks one more folder as a
52
+ // real home, so a test can prove the fence without touching the real one.
53
+ if (env.ATRIS_TEST_PROTECTED_HOME) homes.push(env.ATRIS_TEST_PROTECTED_HOME);
54
+ return homes;
55
+ }
56
+
57
+ realHomeOnce();
58
+
59
+ function underTest(env) {
60
+ return Boolean(env.NODE_TEST_CONTEXT || env.ATRIS_TEST_STATE_DIR);
61
+ }
62
+
63
+ // os.homedir() follows HOME and any test mock of it; a caller-supplied env
64
+ // with its own HOME (like a spawned child's env) takes that instead.
65
+ function currentHome(env) {
66
+ return (env !== process.env && env.HOME) || os.homedir();
67
+ }
68
+
69
+ function testStateFenced(env = process.env) {
70
+ if (env.ATRIS_TEST_REAL_HOME === '1') return false;
71
+ if (!underTest(env)) return false;
72
+ const home = currentHome(env);
73
+ const real = protectedHomes(env);
74
+ // If we cannot tell who the real user is, assume HOME is real.
75
+ if (real.length === 0) return true;
76
+ return real.some((h) => samePath(home, h));
77
+ }
78
+
79
+ // One fence folder per OS user, shared by every test run. Tests that need
80
+ // isolation already set their own paths; tests that did not were sharing the
81
+ // real db, so sharing one throwaway db keeps that behavior without the damage.
82
+ // The choice is exported through the env so children that lose
83
+ // NODE_TEST_CONTEXT still stay fenced.
84
+ function fenceDir(env) {
85
+ if (env.ATRIS_TEST_STATE_DIR) return env.ATRIS_TEST_STATE_DIR;
86
+ let user = typeof process.getuid === 'function' ? process.getuid() : null;
87
+ if (user === null) {
88
+ try { user = os.userInfo().username; } catch (_) { user = 'user'; }
89
+ }
90
+ const dir = path.join(os.tmpdir(), `atris-test-home-${user}`);
91
+ if (env === process.env) process.env.ATRIS_TEST_STATE_DIR = dir;
92
+ return dir;
93
+ }
94
+
95
+ // The folder that holds .atris: the real home normally, a throwaway one when
96
+ // a test run would otherwise reach the real home.
97
+ function stateHome(env = process.env) {
98
+ if (testStateFenced(env)) return fenceDir(env);
99
+ return currentHome(env);
100
+ }
101
+
102
+ function atrisHome(env = process.env) {
103
+ return path.join(stateHome(env), '.atris');
104
+ }
105
+
106
+ module.exports = { stateHome, atrisHome, testStateFenced };
package/lib/task-db.js CHANGED
@@ -24,7 +24,6 @@
24
24
 
25
25
  const fs = require('fs');
26
26
  const path = require('path');
27
- const os = require('os');
28
27
  const crypto = require('crypto');
29
28
  const { DatabaseSync } = require('node:sqlite');
30
29
  const reviewIntegrity = require('./review-integrity');
@@ -35,8 +34,8 @@ const { parseVerifyCommand } = require('./auto-accept-certified');
35
34
  const { taskExplanation } = require('./task-explanation');
36
35
  const { treeHashFor } = require('./tree-hash');
37
36
  const { sleepSync } = require('./sleep-sync');
37
+ const { atrisHome } = require('./state-home');
38
38
 
39
- const DEFAULT_DB_PATH = path.join(os.homedir(), '.atris', 'tasks.db');
40
39
  const TASK_EPISODES_FILE = path.join('.atris', 'state', 'task_episodes.jsonl');
41
40
  const TODO_RENDER_DONE_LIMIT = 8;
42
41
  const TODO_RENDER_FAILED_LIMIT = 12;
@@ -188,8 +187,10 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_tasks_source ON tasks(workspace_root, sourc
188
187
  let _cachedDb = null;
189
188
  let _cachedPath = null;
190
189
 
190
+ // Default is ~/.atris/tasks.db, resolved per call so a test run that forgets
191
+ // ATRIS_TASKS_DB lands in a throwaway db instead of the real one.
191
192
  function getDbPath() {
192
- return process.env.ATRIS_TASKS_DB || DEFAULT_DB_PATH;
193
+ return process.env.ATRIS_TASKS_DB || path.join(atrisHome(), 'tasks.db');
193
194
  }
194
195
 
195
196
  function open(dbPath) {