atris 3.62.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 (72) hide show
  1. package/atris/features/README.md +8 -0
  2. package/atris/skills/README.md +1 -0
  3. package/atris/skills/commerce/SKILL.md +120 -0
  4. package/atris/skills/x-search/SKILL.md +3 -1
  5. package/ax +4 -3
  6. package/bin/atris.js +22 -2
  7. package/cli/atris_code.py +25 -1
  8. package/commands/auth.js +79 -10
  9. package/commands/autoland.js +11 -1
  10. package/commands/browse.js +2 -2
  11. package/commands/business.js +3 -3
  12. package/commands/buy.js +242 -0
  13. package/commands/deck.js +2 -2
  14. package/commands/engine.js +122 -13
  15. package/commands/feedback.js +2 -2
  16. package/commands/fork.js +2 -2
  17. package/commands/gm.js +2 -1
  18. package/commands/improve.js +142 -8
  19. package/commands/integrations.js +4 -3
  20. package/commands/loops.js +2 -1
  21. package/commands/member.js +82 -15
  22. package/commands/mission.js +4 -1
  23. package/commands/pack-craft.js +2 -2
  24. package/commands/publish.js +2 -2
  25. package/commands/radar.js +3 -2
  26. package/commands/router.js +2 -2
  27. package/commands/task.js +91 -2
  28. package/commands/transactions.js +115 -0
  29. package/commands/truth.js +2 -2
  30. package/commands/x-search.js +81 -10
  31. package/commands/youtube.js +24 -1
  32. package/lib/autoland.js +3 -2
  33. package/lib/ax-auto-lane.js +2 -2
  34. package/lib/brief-rules.js +11 -0
  35. package/lib/chat-log-scan.js +2 -1
  36. package/lib/ci-runner.js +3 -2
  37. package/lib/codex-flight.js +2 -1
  38. package/lib/commerce.js +90 -0
  39. package/lib/deck-history.js +2 -2
  40. package/lib/deck-review.js +3 -3
  41. package/lib/design-api.js +2 -2
  42. package/lib/engine-registry.js +102 -18
  43. package/lib/first-minute.js +2 -2
  44. package/lib/fleet.js +8 -36
  45. package/lib/git-spawn.js +5 -2
  46. package/lib/ignored-cache.js +51 -0
  47. package/lib/known-commands.js +1 -1
  48. package/lib/manifest.js +2 -2
  49. package/lib/map-refs.js +70 -4
  50. package/lib/member-engine.js +15 -1
  51. package/lib/member-scaffold.js +2 -0
  52. package/lib/member-type.js +27 -0
  53. package/lib/pack-capabilities.js +2 -1
  54. package/lib/permission-grants.js +2 -2
  55. package/lib/pulse.js +5 -5
  56. package/lib/roster-runs.js +13 -2
  57. package/lib/runner-command.js +54 -3
  58. package/lib/sandbox-pids.js +113 -0
  59. package/lib/secret-gateway.js +3 -13
  60. package/lib/state-home.js +106 -0
  61. package/lib/task-db.js +4 -3
  62. package/lib/task-prune.js +462 -0
  63. package/lib/x-post-check.js +497 -0
  64. package/package.json +1 -1
  65. package/scripts/det/data/xsearch-answer.json +24 -0
  66. package/scripts/det/data/xsearch-live-2026-09-29.json +93 -0
  67. package/scripts/det/xsearch-bench.js +307 -0
  68. package/scripts/det/ytnotes +122 -16
  69. package/scripts/det/ytrail-bench.js +215 -0
  70. package/scripts/det/ytrail-eval.js +128 -56
  71. package/utils/auth.js +3 -3
  72. 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
 
@@ -8,6 +8,7 @@
8
8
  // words ("judge: opus 5.5"). The job's pick then comes from the roster.
9
9
 
10
10
  const fs = require('fs');
11
+ const { memberRefusalReason } = require('./member-type');
11
12
  const path = require('path');
12
13
  const {
13
14
  ENGINE_JOBS,
@@ -204,6 +205,7 @@ function memberRosterEngine(memberName, root = process.cwd(), options = {}) {
204
205
  const name = String(memberName || '').trim();
205
206
  if (!name || name.includes('/') || name.includes('\\')) return null;
206
207
  try {
208
+ if (!memberCardRunnable(root, name)) return null;
207
209
  const picked = resolveEngineForMember(name, root, options);
208
210
  if (!picked.found && options.requireMember !== false) return null;
209
211
  return picked.roster_backed && picked.engine ? picked : null;
@@ -212,6 +214,18 @@ function memberRosterEngine(memberName, root = process.cwd(), options = {}) {
212
214
  }
213
215
  }
214
216
 
217
+ // A person or odd-typed card never runs on an engine.
218
+ function memberCardRunnable(root, name) {
219
+ const card = findMemberCard(root, name);
220
+ if (!card) return true;
221
+ try {
222
+ const block = /^---\r?\n([\s\S]*?)\r?\n---/.exec(fs.readFileSync(card, 'utf8'));
223
+ const match = block ? /^type:\s*(.*)$/m.exec(block[1]) : null;
224
+ const value = match ? match[1].trim().replace(/^["']|["']$/g, '') : '';
225
+ return !memberRefusalReason(name, value ? { type: value } : {});
226
+ } catch { return true; }
227
+ }
228
+
215
229
  // A parked card stays on disk and still runs by name; this view (the roster
216
230
  // team block and the boot lineup) just leaves it out.
217
231
  function memberCardParked(root, name) {
@@ -232,7 +246,7 @@ function teamRosterView(root = process.cwd(), options = {}) {
232
246
  else if (layer.scope === 'project') warnings.push({ file: line.file, line: line.line, text: line.text, message: `there is no team member named ${line.member}, so the line does nothing` });
233
247
  }
234
248
  }
235
- for (const name of [...names]) if (memberCardParked(root, name)) names.delete(name);
249
+ for (const name of [...names]) if (memberCardParked(root, name) || !memberCardRunnable(root, name)) names.delete(name);
236
250
  const rows = [...names].sort().map((name) => {
237
251
  const picked = resolveEngineForMember(name, root, { ...options, rosterState: state });
238
252
  const line = teamLineFor(state, name);
@@ -26,6 +26,8 @@ name: ${name}
26
26
  role: ${title}
27
27
  description: ${mission}
28
28
  version: 1.0.0
29
+ # ai or human; a human member is a person and Atris never acts as them
30
+ type: ai
29
31
 
30
32
  skills: []
31
33
 
@@ -0,0 +1,27 @@
1
+ 'use strict';
2
+
3
+ // MEMBER.md `type:` says who a member is. Missing means ai and `agent` is an
4
+ // alias for ai. `human` is a person: Atris can prepare work for them but never
5
+ // acts as them. Any other value keeps the file readable but is never run.
6
+
7
+ function memberTypeOf(frontmatter) {
8
+ const raw = frontmatter && frontmatter.type;
9
+ const value = typeof raw === 'string' ? raw.trim() : '';
10
+ if (!value) return { type: 'ai', raw: '', runnable: true };
11
+ const lowered = value.toLowerCase();
12
+ if (lowered === 'ai' || lowered === 'agent') return { type: 'ai', raw: value, runnable: true };
13
+ if (lowered === 'human') return { type: 'human', raw: value, runnable: false };
14
+ return { type: 'other', raw: value, runnable: false };
15
+ }
16
+
17
+ // Plain sentence for why a member will not run; '' when it can run.
18
+ function memberRefusalReason(name, frontmatter) {
19
+ const info = memberTypeOf(frontmatter);
20
+ if (info.runnable) return '';
21
+ if (info.type === 'human') {
22
+ return `${name} is a person, not an AI teammate; Atris can prepare work for them but cannot act as them`;
23
+ }
24
+ return `${name} has type '${info.raw}', which is neither ai nor human, so Atris won't run it`;
25
+ }
26
+
27
+ module.exports = { memberTypeOf, memberRefusalReason };
@@ -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);
@@ -38,10 +38,12 @@ const RUNNER_PROFILE_DEFS = Object.freeze({
38
38
  efforts: CLAUDE_EFFORTS,
39
39
  }),
40
40
  // No -m or effort unless the roster pins one: codex then reads its own
41
- // ~/.codex/config.toml, which is what the roster view reports.
41
+ // ~/.codex/config.toml, which is what the roster view reports. Codex runs
42
+ // only OpenAI models, so a claude model name never reaches it.
42
43
  codex: Object.freeze({
43
44
  bin: 'codex',
44
45
  model: '',
46
+ ownModelsOnly: true,
45
47
  commandTemplate: '{bin} exec {pinnedModelFlag} {pinnedEffortFlag} {prompt}',
46
48
  effortFlag: '-c model_reasoning_effort=',
47
49
  efforts: Object.freeze(['low', 'medium', 'high', 'xhigh']),
@@ -85,9 +87,11 @@ const RUNNER_PROFILE_DEFS = Object.freeze({
85
87
  }),
86
88
  // No hard pin: a pinned grok-4.6 went stale when the grok CLI moved its
87
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.
88
91
  grok: Object.freeze({
89
92
  bin: 'grok',
90
93
  model: '',
94
+ ownModelsOnly: true,
91
95
  commandTemplate: '{bin} --always-approve {pinnedModelFlag} {pinnedEffortFlag} -p {prompt}',
92
96
  effortFlag: '--reasoning-effort',
93
97
  efforts: Object.freeze(['low', 'medium', 'high']),
@@ -211,6 +215,30 @@ function resolvePinnedRunnerModel(mission) {
211
215
  return explicit || runnerProfileValue('model');
212
216
  }
213
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
+
229
+ // The model an engine is launched with. An engine that runs only its own
230
+ // vendor's models drops a claude model name (a mission's stored default, a
231
+ // claude env pin) and rides its own default instead.
232
+ function runnerModelFor(engineId, model) {
233
+ const value = String(model || '').trim();
234
+ const name = String(engineId || '').trim().toLowerCase();
235
+ const def = RUNNER_PROFILE_DEFS[RUNNER_PROFILE_ALIASES[name] || name];
236
+ if (!value || !def || !def.ownModelsOnly) return value;
237
+ // Required here, not at the top: engine-registry requires this file.
238
+ const { isClaudeFamilyModel } = require('./engine-registry');
239
+ return isClaudeFamilyModel(value) ? '' : value;
240
+ }
241
+
214
242
  function resolveClaudeRunnerBin() {
215
243
  const env = firstConfiguredEnv(['ATRIS_RUNNER_BIN']);
216
244
  if (env) return env;
@@ -265,6 +293,14 @@ function renderRunnerCommandTemplate(template, { promptFile, allowedTools, model
265
293
  // The pinned flags take their leading space with them, so an unpinned
266
294
  // launch reads exactly like the template without the placeholders.
267
295
  template = template.replace(/\s*\{pinnedModelFlag\}/g, pinnedModel ? ` --model ${shellWord(pinnedModel)}` : '');
296
+ // A model dropped for an engine that cannot run it leaves no gap either,
297
+ // and a literal "--model {model}", "-m{model}" or a quoted "{model}" goes
298
+ // with it, quotes and all.
299
+ if (!model) {
300
+ template = template
301
+ .replace(/\s*\{modelFlag\}/g, '')
302
+ .replace(/\s*(?:--model|-m)(?:\s+|=)?(["']?)\{model\}\1/g, '');
303
+ }
268
304
  // Checked even when the template has no slot, so an engine that cannot
269
305
  // take effort refuses instead of running at its default.
270
306
  const effortFlag = runnerEffortFlag(effort);
@@ -298,11 +334,25 @@ function buildRunnerCommand({ promptFile, allowedTools, model, effort } = {}) {
298
334
  }
299
335
  const resolved = resolveClaudeRunnerModel({ model });
300
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));
301
343
  if (template) {
302
- return renderRunnerCommandTemplate(template, { promptFile, allowedTools, model: resolved, pinnedModel: resolvePinnedRunnerModel({ model }), effort });
344
+ return renderRunnerCommandTemplate(template, {
345
+ promptFile,
346
+ allowedTools,
347
+ model: modelFor(resolved),
348
+ pinnedModel: modelFor(resolvePinnedRunnerModel({ model })),
349
+ effort,
350
+ });
303
351
  }
304
352
  const safePath = String(promptFile).replace(/'/g, "'\\''");
305
- 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)}`;
306
356
  const effortFlag = runnerEffortFlag(effort);
307
357
  if (effortFlag) cmd += ` ${effortFlag}`;
308
358
  if (allowedTools) {
@@ -339,6 +389,7 @@ module.exports = {
339
389
  resolveRunnerProfile,
340
390
  resolveClaudeRunnerModel,
341
391
  resolvePinnedRunnerModel,
392
+ runnerModelFor,
342
393
  resolveClaudeRunnerBin,
343
394
  resolveClaudeRunnerCommandTemplate,
344
395
  buildRunnerAvailabilityCommand,
@@ -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 };