atris 3.63.0 → 3.65.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.
- package/README.md +4 -0
- package/atris/skills/commerce/SKILL.md +24 -0
- package/atris/skills/x-search/SKILL.md +3 -1
- package/ax +4 -3
- package/bin/atris.js +28 -2
- package/cli/atris_code.py +25 -1
- package/commands/agent-spawn.js +1 -0
- package/commands/auth.js +79 -10
- package/commands/autoland.js +11 -1
- package/commands/browse.js +2 -2
- package/commands/business.js +3 -3
- package/commands/buy.js +3 -3
- package/commands/deck.js +2 -2
- package/commands/engine.js +292 -65
- package/commands/feedback.js +2 -2
- package/commands/fork.js +2 -2
- package/commands/gm.js +2 -1
- package/commands/hire.js +189 -0
- package/commands/improve.js +142 -8
- package/commands/integrations.js +4 -3
- package/commands/loops.js +2 -1
- package/commands/mail.js +163 -0
- package/commands/member.js +47 -9
- package/commands/mission.js +4 -3
- package/commands/pack-craft.js +2 -2
- package/commands/publish.js +2 -2
- package/commands/radar.js +3 -2
- package/commands/router.js +2 -2
- package/commands/stream.js +22 -1
- package/commands/task.js +84 -2
- package/commands/team.js +355 -67
- package/commands/transactions.js +3 -3
- package/commands/truth.js +2 -2
- package/commands/x-search.js +81 -10
- package/commands/youtube.js +2 -1
- package/lib/auto-accept-certified.js +83 -9
- package/lib/autoland.js +3 -2
- package/lib/ax-auto-lane.js +2 -2
- package/lib/brief-rules.js +11 -0
- package/lib/business-target.js +131 -0
- package/lib/chat-log-scan.js +2 -1
- package/lib/ci-runner.js +3 -2
- package/lib/codex-flight.js +2 -1
- package/lib/commerce.js +17 -2
- package/lib/deck-history.js +2 -2
- package/lib/deck-review.js +3 -3
- package/lib/design-api.js +2 -2
- package/lib/engine-processes.js +430 -0
- package/lib/engine-registry.js +101 -21
- package/lib/first-minute.js +2 -2
- package/lib/fleet.js +8 -36
- package/lib/git-spawn.js +5 -2
- package/lib/ignored-cache.js +51 -0
- package/lib/known-commands.js +1 -1
- package/lib/manifest.js +2 -2
- package/lib/map-refs.js +70 -4
- package/lib/member-identity.js +56 -0
- package/lib/pack-capabilities.js +2 -1
- package/lib/permission-grants.js +2 -2
- package/lib/pulse.js +5 -5
- package/lib/roster-models.js +11 -0
- package/lib/roster-runs.js +13 -2
- package/lib/roster-stale.js +241 -0
- package/lib/runner-command.js +22 -6
- package/lib/sandbox-pids.js +113 -0
- package/lib/secret-gateway.js +3 -13
- package/lib/state-home.js +106 -0
- package/lib/task-db.js +4 -3
- package/lib/task-prune.js +462 -0
- package/lib/team-lineup.js +10 -71
- package/lib/team-presence.js +224 -0
- package/lib/text-table.js +81 -0
- package/lib/x-post-check.js +497 -0
- package/package.json +1 -1
- package/scripts/det/data/xsearch-answer.json +24 -0
- package/scripts/det/data/xsearch-live-2026-09-29.json +93 -0
- package/scripts/det/xsearch-bench.js +307 -0
- package/scripts/det/ytnotes +61 -15
- package/scripts/det/ytrail-bench.js +2 -1
- package/utils/auth.js +3 -3
- package/utils/update-check.js +2 -2
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// How a launch names the member it runs as, in one place. The writers use
|
|
4
|
+
// these lines and the team view reads them back with patterns built from the
|
|
5
|
+
// same lines, so the wording cannot drift apart.
|
|
6
|
+
//
|
|
7
|
+
// Mission ticks (atris member run, atris mission) say:
|
|
8
|
+
// You are the member "validator".
|
|
9
|
+
// **Objective:** <what the run is for>
|
|
10
|
+
// Dispatch briefs say:
|
|
11
|
+
// You are acting as validator. <the work>
|
|
12
|
+
|
|
13
|
+
const escapeRegExp = require('./escape-regexp');
|
|
14
|
+
|
|
15
|
+
const NAME = '([a-z0-9][a-z0-9_-]*[a-z0-9])';
|
|
16
|
+
const SLOT = '\u0000';
|
|
17
|
+
|
|
18
|
+
function memberIdentityLine(name) {
|
|
19
|
+
return `You are the member "${name}".`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function missionObjectiveLine(objective) {
|
|
23
|
+
return `**Objective:** ${objective}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function patternFrom(line, name) {
|
|
27
|
+
return new RegExp(escapeRegExp(line).replace(escapeRegExp(SLOT), name), 'i');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const MISSION_MEMBER = patternFrom(memberIdentityLine(SLOT), NAME);
|
|
31
|
+
const DISPATCH_MEMBER = /\bacting as (?:the )?([a-z0-9][a-z0-9_-]*[a-z0-9])[.,:;]?/i;
|
|
32
|
+
const OBJECTIVE = patternFrom(missionObjectiveLine(SLOT), '(.+?)(?=\\s\\*\\*[A-Z][^*]*:\\*\\*|\\s#|$)');
|
|
33
|
+
|
|
34
|
+
// One prompt -> { member, doing }. The member is the name the prompt runs
|
|
35
|
+
// as, or null. doing is what the run is for: a mission's objective, or the
|
|
36
|
+
// words after the dispatch line.
|
|
37
|
+
function readMemberLaunch(prompt) {
|
|
38
|
+
const text = String(prompt || '').replace(/\\0?12|\\n/g, ' ').replace(/\s+/g, ' ').trim();
|
|
39
|
+
const mission = MISSION_MEMBER.exec(text);
|
|
40
|
+
if (mission) {
|
|
41
|
+
const objective = OBJECTIVE.exec(text);
|
|
42
|
+
return { member: mission[1].toLowerCase(), doing: objective ? objective[1].trim() : text };
|
|
43
|
+
}
|
|
44
|
+
const dispatch = DISPATCH_MEMBER.exec(text);
|
|
45
|
+
if (dispatch) {
|
|
46
|
+
const after = text.slice(dispatch.index + dispatch[0].length).trim();
|
|
47
|
+
return { member: dispatch[1].toLowerCase(), doing: after || text };
|
|
48
|
+
}
|
|
49
|
+
return { member: null, doing: text };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
module.exports = {
|
|
53
|
+
memberIdentityLine,
|
|
54
|
+
missionObjectiveLine,
|
|
55
|
+
readMemberLaunch,
|
|
56
|
+
};
|
package/lib/pack-capabilities.js
CHANGED
|
@@ -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(
|
|
254
|
+
return path.join(stateHome(), '.atris', 'runs', 'packs');
|
|
254
255
|
}
|
|
255
256
|
|
|
256
257
|
function appendReceiptEvent(eventsPath, event) {
|
package/lib/permission-grants.js
CHANGED
|
@@ -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(
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 ||
|
|
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);
|
package/lib/roster-models.js
CHANGED
|
@@ -97,6 +97,16 @@ function engineRunsView(engineId, { model = '', effort = '' } = {}, options = {}
|
|
|
97
97
|
return view;
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
// "<engine> · <model label>" for a table cell: the tool, a middle dot, and the
|
|
101
|
+
// model label, or "own default" when the tool picks its model itself.
|
|
102
|
+
// Effort is left out; tables give it its own line.
|
|
103
|
+
function engineModelText(engine, model = '') {
|
|
104
|
+
const id = String(engine || '').trim();
|
|
105
|
+
if (!id) return '';
|
|
106
|
+
const label = model ? modelLabel(model) : '';
|
|
107
|
+
return `${id} · ${label || 'own default'}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
100
110
|
function runsDetail(view) {
|
|
101
111
|
const items = [{ text: view.model ? modelLabel(view.model) : 'its own default', source: view.model ? view.model_source : 'roster' }];
|
|
102
112
|
if (view.effort) items.push({ text: view.effort, source: view.effort_source });
|
|
@@ -172,6 +182,7 @@ function availableModels(engines, options = {}) {
|
|
|
172
182
|
}
|
|
173
183
|
|
|
174
184
|
module.exports = {
|
|
185
|
+
engineModelText,
|
|
175
186
|
modelLabel,
|
|
176
187
|
readCodexSettings,
|
|
177
188
|
engineRunsView,
|
package/lib/roster-runs.js
CHANGED
|
@@ -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(
|
|
50
|
+
const dir = path.dirname(rosterRunsPath(root));
|
|
40
51
|
let names;
|
|
41
52
|
try {
|
|
42
53
|
names = fs.readdirSync(dir);
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Outdated models on the roster. A roster worker whose model has a newer
|
|
4
|
+
// version among the models this machine or the engine registry already
|
|
5
|
+
// knows gets one plain line and the command that moves it forward. A pinned
|
|
6
|
+
// model whose newest proof (a "verified <date>" on its line, or a landed run
|
|
7
|
+
// on that model) is over 30 days old gets one line too. Nothing here goes
|
|
8
|
+
// online: the model lists come from availableModels (codex's own models
|
|
9
|
+
// cache, the claude names atris knows, and each engine's registry models).
|
|
10
|
+
|
|
11
|
+
const { modelLabel, availableModels } = require('./roster-models');
|
|
12
|
+
const { parseRosterUntil, rosterMaxText, rosterToolLabel } = require('./engine-registry');
|
|
13
|
+
|
|
14
|
+
const PIN_MAX_AGE_DAYS = 30;
|
|
15
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
16
|
+
const CLAUDE_FAMILY = Object.freeze(['claude', 'fable', 'haiku']);
|
|
17
|
+
|
|
18
|
+
// Family ordering across names is ambiguous: going from a "luna" tail to a
|
|
19
|
+
// "sol" tail one version up is a tier change as much as a version change,
|
|
20
|
+
// and nothing local says which tails rank above which. So versions are only
|
|
21
|
+
// compared within an identical name prefix, the text before the first
|
|
22
|
+
// version number: "x-6-astra" and "x-6.1-sol" share the prefix "x", and two
|
|
23
|
+
// names with different prefixes (sonnet and opus) never compare. Among
|
|
24
|
+
// newer versions, one that keeps the same tail (sol, fast, flash-high) wins;
|
|
25
|
+
// otherwise the highest version, and on a tie the one the list names first.
|
|
26
|
+
// Examples stay abstract here so this file never pins a real model name.
|
|
27
|
+
// A context tag at the end ("[1m]", "(beta)") is not part of the name, and
|
|
28
|
+
// a leading "claude-" before a family word is the vendor, not the family,
|
|
29
|
+
// so "claude-x-4-6[1m]" and "x 5" land in the same family.
|
|
30
|
+
function familyLabel(model) {
|
|
31
|
+
let text = String(model || '').trim();
|
|
32
|
+
while (/\s*(?:\[[^\]]*\]|\([^)]*\))$/.test(text)) text = text.replace(/\s*(?:\[[^\]]*\]|\([^)]*\))$/, '');
|
|
33
|
+
return modelLabel(text).toLowerCase().trim().replace(/^claude[\s_-]+(?=[a-z])/, '');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function modelFamily(model) {
|
|
37
|
+
const label = familyLabel(model);
|
|
38
|
+
const match = /^([a-z][a-z/_\s-]*?)[\s_-]*(\d+(?:[.-]\d{1,2}(?!\d))*)(.*)$/.exec(label);
|
|
39
|
+
if (!match) return null;
|
|
40
|
+
const prefix = match[1].replace(/[\s_-]+/g, '-').replace(/^-+|-+$/g, '');
|
|
41
|
+
if (!prefix) return null;
|
|
42
|
+
return {
|
|
43
|
+
prefix,
|
|
44
|
+
version: match[2].split(/[.-]/).map(Number),
|
|
45
|
+
variant: match[3].replace(/[\s_-]+/g, '-').replace(/^-+|-+$/g, ''),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function compareVersions(a, b) {
|
|
50
|
+
const size = Math.max(a.length, b.length);
|
|
51
|
+
for (let i = 0; i < size; i += 1) {
|
|
52
|
+
const diff = (a[i] || 0) - (b[i] || 0);
|
|
53
|
+
if (diff) return diff > 0 ? 1 : -1;
|
|
54
|
+
}
|
|
55
|
+
return 0;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function sameModel(a, b) {
|
|
59
|
+
const left = modelLabel(a).toLowerCase().trim();
|
|
60
|
+
return Boolean(left) && left === modelLabel(b).toLowerCase().trim();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// The newest model in the list that beats this one, or null.
|
|
64
|
+
function newerModel(model, catalog = []) {
|
|
65
|
+
const current = modelFamily(model);
|
|
66
|
+
if (!current) return null;
|
|
67
|
+
let best = null;
|
|
68
|
+
for (const candidate of catalog || []) {
|
|
69
|
+
const family = modelFamily(candidate);
|
|
70
|
+
if (!family || family.prefix !== current.prefix) continue;
|
|
71
|
+
if (compareVersions(family.version, current.version) <= 0) continue;
|
|
72
|
+
const sameTail = family.variant === current.variant;
|
|
73
|
+
const better = !best
|
|
74
|
+
|| (sameTail && !best.sameTail)
|
|
75
|
+
|| (sameTail === best.sameTail && compareVersions(family.version, best.family.version) > 0);
|
|
76
|
+
if (better) best = { candidate, family, sameTail };
|
|
77
|
+
}
|
|
78
|
+
return best ? best.candidate : null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Every model each engine is known to offer, from local files only. Claude,
|
|
82
|
+
// fable, and haiku run through the same CLI, so they share one list.
|
|
83
|
+
function modelCatalog(engines, options = {}) {
|
|
84
|
+
const catalog = {};
|
|
85
|
+
const add = (id, models) => {
|
|
86
|
+
const list = catalog[id] || (catalog[id] = []);
|
|
87
|
+
for (const model of models || []) if (model && !list.includes(model)) list.push(model);
|
|
88
|
+
};
|
|
89
|
+
const rows = availableModels(engines || [], options);
|
|
90
|
+
for (const row of rows) add(row.engine, row.models);
|
|
91
|
+
for (const engine of engines || []) add(engine.id, engine.models);
|
|
92
|
+
const claude = (rows.find((row) => row.engine === 'claude') || {}).models || [];
|
|
93
|
+
for (const id of CLAUDE_FAMILY) if (catalog[id]) add(id, claude);
|
|
94
|
+
return catalog;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function localDayText(date) {
|
|
98
|
+
const pad = (value) => String(value).padStart(2, '0');
|
|
99
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function wholeDaysSince(dayText, now) {
|
|
103
|
+
const day = parseRosterUntil(dayText);
|
|
104
|
+
if (!day) return null;
|
|
105
|
+
const date = new Date(now);
|
|
106
|
+
const today = new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
|
107
|
+
return Math.round((today.getTime() - day.getTime()) / DAY_MS);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// The newest proof that this engine and model answer: the line's own
|
|
111
|
+
// "verified" date or the newest landed run on the same engine and model.
|
|
112
|
+
function pinVerified(worker, runs) {
|
|
113
|
+
let best = worker.verified ? { at: worker.verified, source: 'roster line' } : null;
|
|
114
|
+
for (const run of runs || []) {
|
|
115
|
+
if (run.outcome !== 'landed' || run.engine !== worker.engine || !sameModel(run.model, worker.model)) continue;
|
|
116
|
+
const at = Date.parse(run.at);
|
|
117
|
+
if (!Number.isFinite(at)) continue;
|
|
118
|
+
const day = localDayText(new Date(at));
|
|
119
|
+
if (!best || day > best.at) best = { at: day, source: 'landed run' };
|
|
120
|
+
}
|
|
121
|
+
return best;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function quoteArg(text) {
|
|
125
|
+
const value = String(text || '');
|
|
126
|
+
return /^[A-Za-z0-9._:/@-]+$/.test(value) ? value : `"${value.replace(/"/g, '\\"')}"`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const SCOPE_FLAGS = Object.freeze({ 'all projects': '--everywhere', 'this session': '--session' });
|
|
130
|
+
|
|
131
|
+
// The assign command that moves a job's first worker to the newer model and
|
|
132
|
+
// keeps everything else on its line: effort, time cap, prep, end date.
|
|
133
|
+
function assignCommand(row, worker, model) {
|
|
134
|
+
const parts = ['atris engine assign', quoteArg(row.job), worker.engine, '--model', quoteArg(modelLabel(model))];
|
|
135
|
+
if (worker.effort) parts.push('--effort', worker.effort);
|
|
136
|
+
if (worker.max_seconds) parts.push('--max', quoteArg(rosterMaxText(worker.max_seconds).replace(/^max /, '')));
|
|
137
|
+
if (worker.prep) parts.push('--prep', quoteArg(worker.prep));
|
|
138
|
+
if (worker.until && Number(worker.ends_in_days) > 0) parts.push('--days', String(worker.ends_in_days));
|
|
139
|
+
if (SCOPE_FLAGS[row.from]) parts.push(SCOPE_FLAGS[row.from]);
|
|
140
|
+
return parts.join(' ');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function lineHint(file, line, change) {
|
|
144
|
+
const where = line ? `line ${line} of ${file || 'the roster'}` : (file || 'the roster');
|
|
145
|
+
return `${change} on ${where}`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Mark every worker and job in a roster report with stale_model,
|
|
149
|
+
// newer_model, pin_age_days, and pin_verified_at, and list what needs a
|
|
150
|
+
// look in report.stale. Returns the report.
|
|
151
|
+
function attachStaleModels(report, { catalog = {}, runs = [], now = new Date() } = {}) {
|
|
152
|
+
const stale = [];
|
|
153
|
+
const today = localDayText(new Date(now));
|
|
154
|
+
for (const row of report.jobs || []) {
|
|
155
|
+
(row.workers || []).forEach((worker, index) => {
|
|
156
|
+
worker.stale_model = false;
|
|
157
|
+
worker.newer_model = null;
|
|
158
|
+
worker.pin_age_days = null;
|
|
159
|
+
worker.pin_verified_at = null;
|
|
160
|
+
const view = worker.runs;
|
|
161
|
+
if (!view || !view.model || !worker.engine || worker.why === 'expired') return;
|
|
162
|
+
const who = index === 0 ? row.job : `${row.job} backup`;
|
|
163
|
+
const tool = rosterToolLabel(worker.engine);
|
|
164
|
+
const newer = newerModel(view.model, catalog[worker.engine]);
|
|
165
|
+
if (newer) {
|
|
166
|
+
worker.stale_model = true;
|
|
167
|
+
worker.newer_model = newer;
|
|
168
|
+
const from = view.model_source === 'roster' ? '' : ` from ${view.model_source}`;
|
|
169
|
+
const renew = index === 0
|
|
170
|
+
? assignCommand(row, worker, newer)
|
|
171
|
+
: lineHint(row.file, worker.line, `change the model to ${modelLabel(newer)}`);
|
|
172
|
+
stale.push({
|
|
173
|
+
kind: 'newer model',
|
|
174
|
+
job: row.job,
|
|
175
|
+
engine: worker.engine,
|
|
176
|
+
model: view.model,
|
|
177
|
+
newer_model: newer,
|
|
178
|
+
from: row.from,
|
|
179
|
+
file: row.file,
|
|
180
|
+
line: worker.line,
|
|
181
|
+
renew,
|
|
182
|
+
text: `${who}: ${tool} runs ${modelLabel(view.model)}${from}, newer ${modelLabel(newer)} is available. renew: ${renew}`,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
if (view.model_source !== 'roster') return;
|
|
186
|
+
const proof = pinVerified({ ...worker, model: view.model }, runs);
|
|
187
|
+
if (!proof) return;
|
|
188
|
+
worker.pin_verified_at = proof.at;
|
|
189
|
+
worker.pin_age_days = wholeDaysSince(proof.at, now);
|
|
190
|
+
if (worker.pin_age_days === null || worker.pin_age_days <= PIN_MAX_AGE_DAYS || newer) return;
|
|
191
|
+
stale.push({
|
|
192
|
+
kind: 'old pin',
|
|
193
|
+
job: row.job,
|
|
194
|
+
engine: worker.engine,
|
|
195
|
+
model: view.model,
|
|
196
|
+
pin_verified_at: proof.at,
|
|
197
|
+
pin_age_days: worker.pin_age_days,
|
|
198
|
+
from: row.from,
|
|
199
|
+
file: row.file,
|
|
200
|
+
line: worker.line,
|
|
201
|
+
text: `${who}: ${tool} on ${modelLabel(view.model)} was last verified ${worker.pin_age_days} days ago (${proof.source}). smoke one prompt, then ${lineHint(row.file, worker.line, `add "verified ${today}"`)}`,
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
// The job's own fields mirror the worker that leads it, else its first.
|
|
205
|
+
const lead = (row.workers || []).find((worker) => worker.status === 'leads') || (row.workers || [])[0] || {};
|
|
206
|
+
row.stale_model = Boolean(lead.stale_model);
|
|
207
|
+
row.newer_model = lead.newer_model || null;
|
|
208
|
+
row.pin_age_days = lead.pin_age_days === undefined ? null : lead.pin_age_days;
|
|
209
|
+
}
|
|
210
|
+
// A member line that pins its own model is checked too; a member that
|
|
211
|
+
// picks automatically runs its job's worker, already checked above.
|
|
212
|
+
for (const member of report.team || []) {
|
|
213
|
+
if (member.source !== 'file' || !member.model || !member.engine) continue;
|
|
214
|
+
const newer = newerModel(member.model, catalog[member.engine]);
|
|
215
|
+
member.stale_model = Boolean(newer);
|
|
216
|
+
member.newer_model = newer;
|
|
217
|
+
if (!newer) continue;
|
|
218
|
+
const renew = `change the model to ${modelLabel(newer)} on the ${member.member} line of ${member.file || 'the roster'}`;
|
|
219
|
+
stale.push({
|
|
220
|
+
kind: 'newer model',
|
|
221
|
+
member: member.member,
|
|
222
|
+
engine: member.engine,
|
|
223
|
+
model: member.model,
|
|
224
|
+
newer_model: newer,
|
|
225
|
+
file: member.file,
|
|
226
|
+
renew,
|
|
227
|
+
text: `${member.member}: ${rosterToolLabel(member.engine)} runs ${modelLabel(member.model)}, newer ${modelLabel(newer)} is available. renew: ${renew}`,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
report.stale = stale;
|
|
231
|
+
return report;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
module.exports = {
|
|
235
|
+
PIN_MAX_AGE_DAYS,
|
|
236
|
+
attachStaleModels,
|
|
237
|
+
compareVersions,
|
|
238
|
+
modelCatalog,
|
|
239
|
+
modelFamily,
|
|
240
|
+
newerModel,
|
|
241
|
+
};
|
package/lib/runner-command.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
};
|
package/lib/secret-gateway.js
CHANGED
|
@@ -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
|
-
'
|
|
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 {}',
|