agent-policy-kit 0.0.0-stage → 0.2.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/AGENT_INSTALL.md +79 -0
- package/CHANGELOG.md +101 -0
- package/LICENSE +21 -0
- package/README.md +69 -2
- package/bin/agent-policy.mjs +157 -0
- package/docs/architecture.md +17 -0
- package/docs/compatibility.md +23 -0
- package/docs/configuration-page.md +44 -0
- package/docs/deployment.md +31 -0
- package/docs/design/2026-11-01-grok-build-support.md +30 -0
- package/docs/development.md +21 -0
- package/docs/installation.md +23 -0
- package/docs/model-catalog.md +102 -0
- package/docs/model-updates.md +22 -0
- package/docs/npm-release.md +27 -0
- package/docs/policy.md +16 -0
- package/docs/privacy.md +9 -0
- package/docs/recovery.md +13 -0
- package/docs/releases.md +40 -0
- package/docs/use-cases.md +25 -0
- package/docs/verification.md +45 -0
- package/examples/kimi-pool.toml +17 -0
- package/models/README.md +7 -0
- package/models/catalog.json +743 -0
- package/package.json +63 -4
- package/policies/balanced.json +119 -0
- package/policies/fast.json +81 -0
- package/policies/quality.json +111 -0
- package/schema/policy.schema.json +459 -0
- package/site/index.html +1049 -0
- package/src/detect.mjs +137 -0
- package/src/explicit.mjs +89 -0
- package/src/inspect.mjs +203 -0
- package/src/install.mjs +582 -0
- package/src/native.mjs +64 -0
- package/src/output.mjs +108 -0
- package/src/policy.mjs +45 -0
- package/src/release.mjs +19 -0
- package/src/roles.mjs +13 -0
- package/src/setup.mjs +66 -0
- package/src/share.mjs +172 -0
- package/src/toml.mjs +96 -0
package/src/native.mjs
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { ROLES, rolePreference } from './policy.mjs';
|
|
3
|
+
import {ROLE_DEFINITIONS,ROLE_IDS} from './roles.mjs';
|
|
4
|
+
|
|
5
|
+
export const BEGIN = '<!-- BEGIN agent-policy-kit -->';
|
|
6
|
+
export const END = '<!-- END agent-policy-kit -->';
|
|
7
|
+
const directory = {codex:'.codex',claude:'.claude',kimi:'.kimi-code',agy:'.gemini',grok:'.grok'};
|
|
8
|
+
export function nativeHomeConflict(client,home,env) {
|
|
9
|
+
const variable={codex:'CODEX_HOME',claude:'CLAUDE_CONFIG_DIR',kimi:'KIMI_CODE_HOME',grok:'GROK_HOME'}[client];
|
|
10
|
+
const active=variable&&env[variable];
|
|
11
|
+
return typeof active==='string'&&active.length>0&&path.resolve(active)!==path.resolve(home,directory[client]);
|
|
12
|
+
}
|
|
13
|
+
export function nativeLocations(client,home) {
|
|
14
|
+
const root=path.join(home,directory[client]);
|
|
15
|
+
return {
|
|
16
|
+
config:client==='agy'?null:path.join(root,client==='claude'?'settings.json':'config.toml'),
|
|
17
|
+
instructions:path.join(root,client==='claude'?'CLAUDE.md':'AGENTS.md'),
|
|
18
|
+
roles:Object.keys(ROLES).map(role=>path.join(root,client==='agy'?'config/agents':'agents',`apk-${role}.${client==='codex'?'toml':'md'}`))
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export function nativeRoles(client,config,selectedRoles=ROLE_IDS) {
|
|
22
|
+
return selectedRoles.map(role=>{
|
|
23
|
+
const selected=rolePreference(config,role),name='apk-'+role;
|
|
24
|
+
const {description,prompt}=ROLE_DEFINITIONS.find(value=>value.id===role);
|
|
25
|
+
if(client==='codex') return Object.entries({name,description,model:selected.model,...(selected.effort===null?{}:{model_reasoning_effort:selected.effort}),developer_instructions:prompt}).map(([k,v])=>`${k} = ${JSON.stringify(v)}\n`).join('');
|
|
26
|
+
const fields=[`name: ${name}`,`description: ${JSON.stringify(description)}`];
|
|
27
|
+
// Grok selects the model from config.toml [subagents.models]; read-only roles use the bundled plan permission mode.
|
|
28
|
+
if(client==='grok') {
|
|
29
|
+
if(['reviewer','explorer'].includes(role))fields.push('permission_mode: plan');
|
|
30
|
+
} else if(client==='kimi') {
|
|
31
|
+
if(['reviewer','explorer'].includes(role))fields.push('tools: [Read, Grep, Glob]');
|
|
32
|
+
} else {
|
|
33
|
+
fields.push(`model: ${JSON.stringify(selected.model)}`);
|
|
34
|
+
if(client==='claude') {
|
|
35
|
+
if(selected.effort!==null)fields.push(`effort: ${selected.effort}`);
|
|
36
|
+
if(['reviewer','explorer'].includes(role))fields.push('tools: Read, Grep, Glob');
|
|
37
|
+
} else fields.push('subagent: true');
|
|
38
|
+
}
|
|
39
|
+
const routing=client==='kimi'?`\nThe parent must select configured pool alias ${selected.model} for this role; Markdown frontmatter does not select a model. Requested effort: ${selected.effort??'native default'}.\n`:'';
|
|
40
|
+
return `---\n${fields.join('\n')}\n---\n\n${prompt}\n${routing}`;
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
export function instructionBlock(client,config,maxConcurrent,selectedRoles=ROLE_IDS) {
|
|
44
|
+
const routing=ROLE_DEFINITIONS.filter(role=>selectedRoles.includes(role.id)).map(role=>`apk-${role.id} for ${role.assignment}`).join(', ');
|
|
45
|
+
const text=[BEGIN,'These preferences apply only if you choose to delegate to subagents. They do not require delegation or change how you handle work directly.',`When delegating, use ${routing}.`,`When delegating, run at most ${maxConcurrent} subagents concurrently in this session. Give each agent one concrete result and explicit file ownership. Preserve other agents' work. Child agents must return further delegation to the parent.`];
|
|
46
|
+
if(client==='kimi') {
|
|
47
|
+
for(const role of selectedRoles) {const selected=rolePreference(config,role);text.push(`For apk-${role}, select pool alias ${selected.model} (effort ${selected.effort??'native default'}).`);}
|
|
48
|
+
text.push('Roles sharing an alias share its native effort default; an explicit effort pin for that alias also affects its native-default roles.');
|
|
49
|
+
text.push("Use the Agent tool's model selection when spawning; the role file alone does not select a model. The concurrency limit is guidance; no native Agent cap is configured.");
|
|
50
|
+
} else if(client==='agy') {
|
|
51
|
+
for(const role of selectedRoles)text.push(`When delegating to apk-${role}, use model tier ${rolePreference(config,role).model}.`);
|
|
52
|
+
text.push('Per-role effort and a hard concurrency cap are not supported by the documented native agent settings.');
|
|
53
|
+
}
|
|
54
|
+
else if(client==='grok')text.push('Use the named role definitions for model selection. Per-role effort is not supported by the documented native agent settings; subagents use the main session effort. Explicit spawn settings and higher-priority instructions can supersede user defaults.');
|
|
55
|
+
else if(client==='claude')text.push('Use the named role definitions for model and effort selection. Session, project, managed settings, environment overrides and some spawn paths can supersede these user defaults; the native cap applies to ordinary Agent-tool spawns with documented exceptions.');
|
|
56
|
+
else text.push('Use the named role definitions for model and effort selection. Explicit spawn settings and higher-priority instructions can supersede user defaults.');
|
|
57
|
+
if(client==='codex'||client==='claude')text.push('An omitted role effort uses the native client default; it does not pin a level.');
|
|
58
|
+
if(config.avoidModels.length)text.push(`Avoid these models when delegating: ${config.avoidModels.join(', ')}. This is routing guidance.`);
|
|
59
|
+
text.push(END);
|
|
60
|
+
return text.join('\n')+'\n';
|
|
61
|
+
}
|
|
62
|
+
export function nativeWarnings(client) {
|
|
63
|
+
return client==='kimi'?['kimi-concurrency-guidance-only','kimi-model-pool-routing-guidance']:client==='agy'?['agy-concurrency-guidance-only','agy-per-role-effort-unsupported']:client==='grok'?['grok-per-role-effort-unsupported','grok-native-precedence-limits']:client==='claude'?['claude-native-precedence-limits']:['codex-native-precedence-limits'];
|
|
64
|
+
}
|
package/src/output.mjs
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import {PRESETS} from './release.mjs';
|
|
2
|
+
const LABELS={codex:'Codex',claude:'Claude Code',kimi:'Kimi',agy:'Antigravity',grok:'Grok Build'};
|
|
3
|
+
const label=client=>LABELS[client]??'Tool';
|
|
4
|
+
const code=value=>typeof value==='string'&&/^[A-Za-z][A-Za-z0-9_-]{0,127}$/.test(value)?value:'OPERATION_FAILED';
|
|
5
|
+
const PRESET_LABELS=Object.fromEntries(PRESETS.map(entry=>[entry.id,entry.label]));
|
|
6
|
+
const preset=value=>Object.hasOwn(PRESET_LABELS,value)?' · '+PRESET_LABELS[value]:'';
|
|
7
|
+
const NOTES={
|
|
8
|
+
'codex-native-precedence-limits':'Project and explicit client settings can override these defaults.',
|
|
9
|
+
'claude-native-precedence-limits':'Project and explicit client settings can override these defaults.',
|
|
10
|
+
'kimi-concurrency-guidance-only':'Kimi: the concurrency limit is guidance.',
|
|
11
|
+
'kimi-model-pool-routing-guidance':'Kimi: the parent selects the model alias for each role.',
|
|
12
|
+
'agy-concurrency-guidance-only':'Antigravity: the concurrency limit is guidance.',
|
|
13
|
+
'agy-per-role-effort-unsupported':'Antigravity: native tiers do not support per-role effort.',
|
|
14
|
+
'grok-per-role-effort-unsupported':'Grok Build: native agent settings do not support per-role effort.',
|
|
15
|
+
'grok-native-precedence-limits':'Project and explicit client settings can override these defaults.',
|
|
16
|
+
'kimi-catalog-alias-resolved':'Kimi: an existing matching model alias was selected.',
|
|
17
|
+
};
|
|
18
|
+
const REASONS={
|
|
19
|
+
OWNED_DRIFT:'Local edits prevent an update',
|
|
20
|
+
'kimi-provisioned-models-required':'Required model aliases are missing',
|
|
21
|
+
'native-home-override-unsupported':'The active client profile uses a nonstandard location',
|
|
22
|
+
'client-not-selected':'This tool is not selected in the configuration',
|
|
23
|
+
'claude-force-model-conflict':'A forced model conflicts with the selected roles',
|
|
24
|
+
'kimi-supported-effort-required':'A selected alias does not declare the required effort support',
|
|
25
|
+
'kimi-main-effort-conflict':'The requested effort would change the main model defaults',
|
|
26
|
+
'kimi-thinking-disabled':'Thinking is disabled for a model that requires it',
|
|
27
|
+
'kimi-mixed-native-default-effort':'A shared effort override conflicts with native-default roles',
|
|
28
|
+
'kimi-catalog-alias-ambiguous':'More than one existing model alias matches',
|
|
29
|
+
'kimi-resolved-model-avoided':'The matching alias is on the avoid list',
|
|
30
|
+
'kimi-resolved-effort-conflict':'Roles sharing an alias request conflicting effort levels',
|
|
31
|
+
INSTALLATION_CONFLICT:'Installed preferences differ from this request',
|
|
32
|
+
POLICY_CONFLICT:'Saved preferences differ from this request',
|
|
33
|
+
RECOVERY_REQUIRED:'An interrupted transaction requires recovery',
|
|
34
|
+
INSTALL_LOCKED:'Another configuration operation holds the lock',
|
|
35
|
+
};
|
|
36
|
+
export const useJsonOutput=({isTTY,json=false}={})=>json||!isTTY;
|
|
37
|
+
function reason(value){const id=code(value);return (REASONS[id]??'Configuration could not be completed')+` (${id})`;}
|
|
38
|
+
function row(client,text,mark='✓'){return `${mark} ${label(client).padEnd(14)} ${text}`;}
|
|
39
|
+
function setup(result,{apply}){
|
|
40
|
+
const results=result.results??[],failed=results.some(r=>['error','prerequisite','invalid','timeout'].includes(r.status));
|
|
41
|
+
const changed=results.filter(r=>r.status==='configured');
|
|
42
|
+
const heading=failed?'Setup incomplete':result.status==='no_clients'?'No supported selected tools found':!apply?'Preview · no files changed':changed.length?'Configuration applied':'Already up to date';
|
|
43
|
+
const lines=[heading,''],missing=[],notes=new Set();
|
|
44
|
+
for(const r of results){
|
|
45
|
+
if(r.status==='not_selected')continue;
|
|
46
|
+
if(r.status==='not_found'){missing.push(label(r.client));continue;}
|
|
47
|
+
const warnings=r.warnings??r.plan?.warnings??[];
|
|
48
|
+
let text,mark='✓';
|
|
49
|
+
if(r.status==='configured')text='Configuration applied'+preset(r.appliedPreset);
|
|
50
|
+
else if(r.status==='unchanged'||r.status==='planned'&&r.plan?.files?.length===0)text='Already up to date'+preset(r.appliedPreset);
|
|
51
|
+
else if(r.status==='planned'){text='Would configure'+preset(r.targetPreset);mark='·';}
|
|
52
|
+
else if(r.status==='invalid'){text='Client executable identity could not be verified';mark='!';}
|
|
53
|
+
else if(r.status==='timeout'){text='Client detection timed out';mark='!';}
|
|
54
|
+
else {text=reason(r.error??warnings[0]);mark='!';}
|
|
55
|
+
lines.push(row(r.client,text,mark));
|
|
56
|
+
if(r.status==='prerequisite'&&r.appliedReleaseVersion&&/^\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.-]+)?$/.test(r.appliedReleaseVersion))lines.push(` Existing configuration retained · ${r.appliedReleaseVersion}${preset(r.appliedPreset)}`);
|
|
57
|
+
if(['configured','unchanged','planned'].includes(r.status))for(const warning of warnings)notes.add(NOTES[warning]??`Client note: ${code(warning)}`);
|
|
58
|
+
}
|
|
59
|
+
if(missing.length)lines.push(`Skipped: ${missing.join(', ')} — not installed.`);
|
|
60
|
+
if(changed.length)lines.push('',`Open a new session in ${changed.map(r=>label(r.client)).join(', ')} to load the changes.`);
|
|
61
|
+
if(results.some(r=>r.error==='OWNED_DRIFT'))lines.push('Review local edits before retrying; APK will not overwrite them.');
|
|
62
|
+
if(results.some(r=>(r.warnings??r.plan?.warnings??[]).includes('kimi-provisioned-models-required')))lines.push('Configure the selected model aliases in Kimi, then run the same command again.');
|
|
63
|
+
if(notes.size)lines.push('',...notes);
|
|
64
|
+
lines.push('Native configuration only; model execution was not checked.');
|
|
65
|
+
return lines;
|
|
66
|
+
}
|
|
67
|
+
function uninstall(result,{apply}){
|
|
68
|
+
const results=result.results??(result.client?[result]:[]);
|
|
69
|
+
const lines=[!apply?'Preview · no files changed':result.status==='partial'?'Removal incomplete':result.status==='not_installed'?'Nothing to remove':'Configuration removal',''];
|
|
70
|
+
for(const r of results){const bad=r.status==='error';lines.push(row(r.client,bad?reason(r.error):r.status==='unchanged'||r.files?.length===0?'Nothing to remove':apply?'Configuration removed':'Would remove',bad?'!':apply?'✓':'·'));}
|
|
71
|
+
return lines;
|
|
72
|
+
}
|
|
73
|
+
const setting=value=>typeof value==='string'&&/^[A-Za-z0-9][A-Za-z0-9 _.:@/\[\]-]{0,127}$/.test(value)?value:'native default';
|
|
74
|
+
function show(result){
|
|
75
|
+
const lines=['User configuration · subagents',''];
|
|
76
|
+
for(const[id,c]of Object.entries(result.clients??{})){
|
|
77
|
+
lines.push(`${label(id)}${c.status==='attention'?' · incomplete read':c.status==='not_configured'?' · no explicit subagent settings':''}`);
|
|
78
|
+
if(c.status!=='not_configured'){
|
|
79
|
+
if(c.defaults.enabled===false)lines.push(' Subagents explicitly disabled.');
|
|
80
|
+
lines.push(` Default model: ${setting(c.defaults.model)} · effort: ${setting(c.defaults.effort)}`);
|
|
81
|
+
lines.push(` Max concurrent: ${Number.isSafeInteger(c.concurrency.value)?c.concurrency.value:'not explicitly set'}${c.concurrency.kind==='guidance'?' (guidance)':' (native setting)'}`);
|
|
82
|
+
for(const role of c.roles)lines.push(` ${setting(role.name).padEnd(22)} ${setting(role.model)} · ${setting(role.effort)}${role.modelSelection==='guidance'?' (routing guidance)':role.modelSelection==='parent'?' (parent selects model)':''}${role.enabled===false?' (delegation disabled)':''}`);
|
|
83
|
+
for(const entry of c.pool)lines.push(` Pool: ${setting(entry.alias)} · ${setting(entry.effort)}${entry.configured?'':' · alias not provisioned'}`);
|
|
84
|
+
for(const[field,value]of Object.entries(c.overrides??{}))lines.push(` Override ${field}: ${typeof value==='boolean'?String(value):typeof value==='number'?value:setting(value)}`);
|
|
85
|
+
}
|
|
86
|
+
if(c.apk.status==='modified')lines.push(' APK-managed files have local edits; the values above come from current files.');
|
|
87
|
+
for(const warning of c.warnings)lines.push(` ! ${code(warning)}`);
|
|
88
|
+
lines.push('');
|
|
89
|
+
}
|
|
90
|
+
lines.push('User-level files only; project, managed and session settings may override them.','Native configuration only; model execution was not checked.');
|
|
91
|
+
return lines;
|
|
92
|
+
}
|
|
93
|
+
export function formatResult(command,result,{version='',apply=false}={}){
|
|
94
|
+
let lines;
|
|
95
|
+
if(result.error)lines=[`Operation failed (${code(result.error)})`];
|
|
96
|
+
else if(command==='setup'||command==='install')lines=setup(result,{apply});
|
|
97
|
+
else if(command==='uninstall')lines=uninstall(result,{apply});
|
|
98
|
+
else if(command==='show')lines=show(result);
|
|
99
|
+
else if(command==='init')lines=['Preferences created.'];
|
|
100
|
+
else if(command==='detect')lines=result.map(r=>row(r.client,r.status==='detected'?`Detected${typeof r.version==='string'&&/^[0-9A-Za-z.+-]{1,128}$/.test(r.version)?' · '+r.version:''}`:r.status==='not_found'?'Not installed':r.status==='timeout'?'Detection timed out':'Identity unverified',r.status==='detected'?'✓':r.status==='not_found'?'·':'!'));
|
|
101
|
+
else if(command==='doctor'){
|
|
102
|
+
lines=['Configuration check',''];
|
|
103
|
+
for(const[client,r]of Object.entries(result.clients??{})){const c=r.configuration;lines.push(row(client,c.status==='verified'?`Verified${preset(c.preset)}`:c.status==='not_configured'?'Not configured':reason(c.reason),c.status==='attention'?'!':c.status==='verified'?'✓':'·'));}
|
|
104
|
+
lines.push('Native configuration only; model execution was not checked.');
|
|
105
|
+
}else if(command==='release')lines=[`Configuration release ${/^\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.-]+)?$/.test(result.version)?result.version:''}${preset(result.preset)}`,'','Use setup --release to preview; add --apply to configure.'];
|
|
106
|
+
else lines=['Operation completed.'];
|
|
107
|
+
return [`Agent Policy Kit${/^\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.-]+)?$/.test(version)?' · '+version:''}`,'',...lines].join('\n')+'\n';
|
|
108
|
+
}
|
package/src/policy.mjs
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import catalog from '../models/catalog.json' with {type:'json'};
|
|
2
|
+
import {ROLES,ROLE_IDS,selectedRoleIds} from './roles.mjs';
|
|
3
|
+
export {ROLES} from './roles.mjs';
|
|
4
|
+
export const CLIENTS = Object.freeze(['codex', 'claude', 'kimi', 'agy', 'grok']);
|
|
5
|
+
export const rolePreference=(config,role)=>config.roles?.[role]??config[ROLES[role]];
|
|
6
|
+
export const NATIVE_EFFORTS=Object.freeze(Object.fromEntries(CLIENTS.map(client=>[client,Object.freeze([...catalog.clients[client].efforts])])));
|
|
7
|
+
const identifier = /^[A-Za-z0-9][A-Za-z0-9._:@/\[\]-]{0,127}$/;
|
|
8
|
+
function invalid() { throw Object.assign(new Error('Invalid shared preferences.'), {code:'INVALID_POLICY'}); }
|
|
9
|
+
function object(value, fields) {
|
|
10
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) || ![Object.prototype,null].includes(Object.getPrototypeOf(value))) invalid();
|
|
11
|
+
for (const [key,descriptor] of Object.entries(Object.getOwnPropertyDescriptors(value))) {
|
|
12
|
+
if (!fields.includes(key) || descriptor.get || descriptor.set) invalid();
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function model(value) { if (typeof value !== 'string' || !identifier.test(value)) invalid(); }
|
|
16
|
+
function freeze(value) { for (const child of Object.values(value)) if (child && typeof child === 'object') freeze(child); return Object.freeze(value); }
|
|
17
|
+
export function validatePolicy(value) {
|
|
18
|
+
object(value,['schemaVersion','maxConcurrent','clients','selectedRoles']);
|
|
19
|
+
if (value.schemaVersion !== 1 || !Number.isInteger(value.maxConcurrent) || value.maxConcurrent < 1 || value.maxConcurrent > 32) invalid();
|
|
20
|
+
if(value.selectedRoles!==undefined&&(!Array.isArray(value.selectedRoles)||!value.selectedRoles.length||value.selectedRoles.length>ROLE_IDS.length||new Set(value.selectedRoles).size!==value.selectedRoles.length||value.selectedRoles.some(role=>!ROLE_IDS.includes(role))))invalid();
|
|
21
|
+
object(value.clients,CLIENTS);
|
|
22
|
+
if (!Object.keys(value.clients).length) invalid();
|
|
23
|
+
for (const [client,config] of Object.entries(value.clients)) {
|
|
24
|
+
object(config,['code','light','roles','avoidModels','maxConcurrent']);
|
|
25
|
+
if(config.maxConcurrent!==undefined&&(!Number.isInteger(config.maxConcurrent)||config.maxConcurrent<1||config.maxConcurrent>32))invalid();
|
|
26
|
+
if(config.roles!==undefined)object(config.roles,Object.keys(ROLES));
|
|
27
|
+
for (const selected of [config.code,config.light,...Object.values(config.roles??{})]) {
|
|
28
|
+
object(selected,['model','effort']); model(selected.model);
|
|
29
|
+
if (client === 'kimi' && selected.model === 'primary') invalid();
|
|
30
|
+
if(client==='agy'&&!['pro','flash','flash_lite','inherit'].includes(selected.model))invalid();
|
|
31
|
+
if(selected.effort!==null){
|
|
32
|
+
if(!NATIVE_EFFORTS[client].includes(selected.effort))invalid();
|
|
33
|
+
const known=catalog.clients[client].models.find(model=>model.id===selected.model);
|
|
34
|
+
if(known&&!known.efforts.includes(selected.effort))invalid();
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (!Array.isArray(config.avoidModels) || config.avoidModels.length > 32 || new Set(config.avoidModels).size !== config.avoidModels.length) invalid();
|
|
38
|
+
config.avoidModels.forEach(model);
|
|
39
|
+
const effective=selectedRoleIds(value).map(role=>rolePreference(config,role));
|
|
40
|
+
if (effective.some(selected=>config.avoidModels.includes(selected.model))) invalid();
|
|
41
|
+
const pinned=effective.filter(value=>value.effort!==null);
|
|
42
|
+
if (client==='kimi'&&new Set(pinned.map(v=>v.model+'\0'+v.effort)).size!==new Set(pinned.map(v=>v.model)).size) invalid();
|
|
43
|
+
}
|
|
44
|
+
return freeze(JSON.parse(JSON.stringify(value)));
|
|
45
|
+
}
|
package/src/release.mjs
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import {createHash} from 'node:crypto';
|
|
3
|
+
import {validatePolicy} from './policy.mjs';
|
|
4
|
+
|
|
5
|
+
export const PACKAGE_VERSION=JSON.parse(fs.readFileSync(new URL('../package.json',import.meta.url),'utf8')).version;
|
|
6
|
+
const canonical=value=>Array.isArray(value)?value.map(canonical):value&&typeof value==='object'?Object.fromEntries(Object.keys(value).sort().map(key=>[key,canonical(value[key])])):value;
|
|
7
|
+
export const PRESETS=Object.freeze([{id:'fast',label:'Light',description:'Light models for every role. Limits last longest.'},{id:'balanced',label:'Standard',description:'Stronger models for code, review, tests and docs. Fits most plans.'},{id:'quality',label:'Top-tier',description:'High-end models for most roles. Uses limits fastest.',topModels:Object.freeze({codex:'gpt-6-astra',claude:'claude-fable-5-1',kimi:'kimi-code/k3.1-pro'})}].map(Object.freeze));
|
|
8
|
+
export function presetPreferences(id='balanced'){
|
|
9
|
+
if(!PRESETS.some(preset=>preset.id===id))throw Object.assign(new Error('Unknown preset.'),{code:'UNKNOWN_PRESET'});
|
|
10
|
+
return validatePolicy(JSON.parse(fs.readFileSync(new URL(`../policies/${id}.json`,import.meta.url),'utf8')));
|
|
11
|
+
}
|
|
12
|
+
export function modelCatalog(){return JSON.parse(fs.readFileSync(new URL('../models/catalog.json',import.meta.url),'utf8'));}
|
|
13
|
+
|
|
14
|
+
export function bundledReleaseInfo(preset='balanced'){
|
|
15
|
+
const preferences=presetPreferences(preset);
|
|
16
|
+
return {schemaVersion:1,version:PACKAGE_VERSION,preset,presets:PRESETS,catalogReviewedAt:modelCatalog().reviewedAt,
|
|
17
|
+
preferencesHash:createHash('sha256').update(JSON.stringify(canonical(preferences))).digest('hex'),
|
|
18
|
+
notes:fs.readFileSync(new URL('../CHANGELOG.md',import.meta.url),'utf8')};
|
|
19
|
+
}
|
package/src/roles.mjs
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
const noChildren=' Do not create child agents; return any need for further delegation to the parent.';
|
|
2
|
+
export const ROLE_DEFINITIONS=Object.freeze([
|
|
3
|
+
{id:'worker',name:'Coder',kind:'code',assignment:'scoped implementation and fixes',description:'Implements scoped changes, preserves peers\' edits, and verifies the result.',prompt:'Implement the assigned change or fix within the assigned files. You share the workspace with other agents: preserve their edits. Verify the result and report changes, checks, and remaining limitations.'},
|
|
4
|
+
{id:'reviewer',name:'Reviewer',kind:'code',assignment:'read-only correctness, security and regression review',description:'Reviews correctness, security and regressions without edits; reports actionable evidence.',prompt:'Review the assigned change without editing files. Find actionable correctness, security, and regression issues. Report each finding with file and line evidence and explain its impact; distinguish confirmed issues from uncertainty.'},
|
|
5
|
+
{id:'explorer',name:'Code explorer',kind:'light',assignment:'read-only code and documentation discovery',description:'Maps code and documentation, cites sources, and separates facts from uncertainty.',prompt:'Explore the assigned code and documentation without editing files. Return a concise map or factual answer with source paths or citations. Distinguish verified facts from assumptions and unknowns.'},
|
|
6
|
+
{id:'tester',name:'Test writer',kind:'code',assignment:'test creation and targeted execution',description:'Writes behavior and edge-case tests, reproduces issues, and runs targeted checks.',prompt:'Own the assigned tests and targeted execution. Reproduce the issue or establish a baseline, then test observable behavior and edge cases. Never weaken checks to make them pass. Do not edit application code unless explicitly assigned. Preserve peers\' edits and report results and limitations.'},
|
|
7
|
+
{id:'writer',name:'Docs writer',kind:'light',assignment:'assigned documentation, examples and release notes',description:'Updates assigned docs and examples, verifies claims, and keeps terminology consistent.',prompt:'Update only the assigned documentation, examples, or release notes. Verify claims against code or cited sources and use consistent terminology. Do not edit application code. Preserve peers\' edits and report changes and unknowns.'},
|
|
8
|
+
{id:'routine',name:'Task runner',kind:'light',assignment:'explicit commands and mechanical edits',description:'Runs prescribed commands and mechanical edits; returns ambiguous decisions to the parent.',prompt:'Execute only the prescribed commands or clearly specified mechanical edits. Preserve other agents\' work and report the outcome. Do not make ambiguous design or behavior decisions; return those decisions to the parent.'}
|
|
9
|
+
].map(role=>Object.freeze({...role,prompt:role.prompt+noChildren})));
|
|
10
|
+
export const ROLES=Object.freeze(Object.fromEntries(ROLE_DEFINITIONS.map(role=>[role.id,role.kind])));
|
|
11
|
+
export const ROLE_IDS=Object.freeze(Object.keys(ROLES));
|
|
12
|
+
export const LEGACY_ROLE_IDS=Object.freeze(['worker','reviewer','explorer','routine']);
|
|
13
|
+
export const selectedRoleIds=policy=>ROLE_IDS.filter(role=>policy.selectedRoles===undefined||policy.selectedRoles.includes(role));
|
package/src/setup.mjs
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { detectClients, resolveClient, summarizeDetection } from './detect.mjs';
|
|
2
|
+
import { planInstall, applyInstall, summarizePlan, inspectConfiguration } from './install.mjs';
|
|
3
|
+
import { nativeHomeConflict } from './native.mjs';
|
|
4
|
+
import { PACKAGE_VERSION } from './release.mjs';
|
|
5
|
+
import { CLIENTS } from './policy.mjs';
|
|
6
|
+
|
|
7
|
+
const ERRORS = new Set(['INVALID_PRESET','INVALID_RELEASE','LEGACY_RUNTIME_RECEIPT','INVALID_CLIENT','INVALID_POLICY','INVALID_JSON','INVALID_TOML','UNSUPPORTED_CONFIG','UNSUPPORTED_TOML_STRUCTURE','UNSUPPORTED_TOML_VALUE','CODEX_CAP_ALIAS_CONFLICT','OWNERSHIP_CONFLICT','INVALID_RECEIPT','OWNED_DRIFT','INSTALLATION_CONFLICT','POLICY_CONFLICT','UNSAFE_PATH','UNSAFE_FILE','STALE_PLAN','RECOVERY_REQUIRED','ROLLBACK_CONFLICT','INVALID_PLAN','PLATFORM_UNSUPPORTED','INSTALL_LOCKED','INSTALL_FAILED','EACCES','EPERM','ENOENT','ENOSPC','EIO','EROFS']);
|
|
8
|
+
const safeError = error => ERRORS.has(error?.code) ? error.code : 'SETUP_FAILED';
|
|
9
|
+
function actions(client,warnings) {
|
|
10
|
+
return warnings.map(code=>({client,code,automatable:false}));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const RELEASE_FIELDS=['targetReleaseVersion','targetSource','previousReleaseVersion','previousSource','appliedReleaseVersion','appliedSource','targetPreset','previousPreset','appliedPreset'];
|
|
14
|
+
function releaseMetadata(value) {return Object.fromEntries(RELEASE_FIELDS.map(key=>[key,value[key]??null]));}
|
|
15
|
+
|
|
16
|
+
export async function setupDetected({home,stateDir,policyPath,apply=false,client=null,env=process.env,hostHome,explicitHome=false,releaseVersion,replace=false,source='release',enabledClients=CLIENTS,preset=null} = {}) {
|
|
17
|
+
if(!Array.isArray(enabledClients)||enabledClients.some(value=>!CLIENTS.includes(value))||new Set(enabledClients).size!==enabledClients.length)throw Object.assign(new Error('INVALID_CLIENT'),{code:'INVALID_CLIENT'});
|
|
18
|
+
const selectionConflict=client!==null&&!enabledClients.includes(client);
|
|
19
|
+
const records = client === null ? await detectClients({env,hostHome}) : [await resolveClient(client,{env,hostHome})];
|
|
20
|
+
const detected = summarizeDetection(records), results = [], requiredActions = [];
|
|
21
|
+
const available = records.filter(r=>r.status === 'detected'&&enabledClients.includes(r.client));
|
|
22
|
+
for (const record of records) {
|
|
23
|
+
if(!enabledClients.includes(record.client)) {
|
|
24
|
+
if(selectionConflict) {const warnings=['client-not-selected'];results.push({client:record.client,status:'prerequisite',warnings});requiredActions.push(...actions(record.client,warnings));}
|
|
25
|
+
else results.push({client:record.client,status:'not_selected'});
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
let retained;
|
|
29
|
+
if(record.status!=='detected'||(!explicitHome&&nativeHomeConflict(record.client,home,env))) {
|
|
30
|
+
try {
|
|
31
|
+
const applied=await inspectConfiguration({client:record.client,home,stateDir});
|
|
32
|
+
retained={targetReleaseVersion:releaseVersion??PACKAGE_VERSION,targetSource:source,previousReleaseVersion:applied.releaseVersion,previousSource:applied.source,appliedReleaseVersion:applied.releaseVersion,appliedSource:applied.source,targetPreset:preset,previousPreset:applied.preset,appliedPreset:applied.preset};
|
|
33
|
+
} catch(error) {
|
|
34
|
+
const code=safeError(error);results.push({client:record.client,status:'error',error:code});requiredActions.push({client:record.client,code,automatable:false});continue;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (record.status !== 'detected') {
|
|
38
|
+
results.push({client:record.client,status:record.status,...retained});
|
|
39
|
+
if (record.status === 'invalid' || record.status === 'timeout') requiredActions.push({client:record.client,code:record.status === 'timeout' ? 'native_client_detection_timeout' : 'native_client_identity_unverified',automatable:true});
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if(!explicitHome&&nativeHomeConflict(record.client,home,env)) {
|
|
43
|
+
const warnings=['native-home-override-unsupported'];
|
|
44
|
+
results.push({client:record.client,status:'prerequisite',warnings,...retained});
|
|
45
|
+
requiredActions.push(...actions(record.client,warnings));
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
// Planning and applying are sequential per client: the preceding transaction
|
|
50
|
+
// can create shared policy/receipts that the next plan must capture afresh.
|
|
51
|
+
const plan = await planInstall({client:record.client,home,stateDir,policyPath,releaseVersion,replace,source,preset});
|
|
52
|
+
requiredActions.push(...actions(record.client,plan.warnings));
|
|
53
|
+
if (plan.status === 'prerequisite') { results.push({client:record.client,status:'prerequisite',plan:summarizePlan(plan),...releaseMetadata(plan)}); continue; }
|
|
54
|
+
if (apply) {
|
|
55
|
+
const result = await applyInstall(plan);
|
|
56
|
+
results.push({client:record.client,status:result.status === 'unchanged' ? 'unchanged' : 'configured',changedFiles:result.changedFiles,warnings:[...result.warnings],...releaseMetadata(result)});
|
|
57
|
+
} else results.push({client:record.client,status:'planned',plan:summarizePlan(plan),...releaseMetadata(plan)});
|
|
58
|
+
} catch (error) {
|
|
59
|
+
const code = safeError(error);
|
|
60
|
+
results.push({client:record.client,status:'error',error:code});
|
|
61
|
+
requiredActions.push({client:record.client,code,automatable:false});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const failed = results.some(r=>['error','prerequisite','invalid','timeout'].includes(r.status));
|
|
65
|
+
return {schemaVersion:1,status:selectionConflict ? 'partial' : available.length === 0 ? 'no_clients' : failed ? 'partial' : apply ? 'configured' : 'planned',detected,results,requiredActions,exitCode:available.length === 0 || failed ? 3 : 0};
|
|
66
|
+
}
|
package/src/share.mjs
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// Pure URL state codec, also embedded in the offline configuration page.
|
|
2
|
+
const SHARE_BYTES=65536,SHARE_TOKEN=87384;
|
|
3
|
+
const SHARE_VERSION=/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*)?$/;
|
|
4
|
+
const SHARE_UNSAFE=new Set(['__proto__','constructor','prototype']);
|
|
5
|
+
function invalidShare(){throw Object.assign(new Error('Invalid shared configuration link.'),{code:'INVALID_SHARE_LINK'});}
|
|
6
|
+
|
|
7
|
+
// Inspect descriptors before reading any user-supplied value. UI-only cached data is
|
|
8
|
+
// allowed, but accessors, exotic prototypes, sparse arrays and hidden fields are not.
|
|
9
|
+
function shareData(value,depth=0,budget={left:100000}){
|
|
10
|
+
if(--budget.left<0||depth>32)invalidShare();
|
|
11
|
+
if(value===null||value===undefined||typeof value==='string'||typeof value==='boolean')return;
|
|
12
|
+
if(typeof value==='number'){if(!Number.isFinite(value))invalidShare();return;}
|
|
13
|
+
if(typeof value!=='object')invalidShare();
|
|
14
|
+
const array=Array.isArray(value),prototype=Object.getPrototypeOf(value);
|
|
15
|
+
if(array?prototype!==Array.prototype:prototype!==Object.prototype&&prototype!==null)invalidShare();
|
|
16
|
+
const fields=Object.getOwnPropertyDescriptors(value),keys=Reflect.ownKeys(fields);
|
|
17
|
+
if(array&&(keys.length!==value.length+1||value.length>100000))invalidShare();
|
|
18
|
+
for(const key of keys){
|
|
19
|
+
if(typeof key!=='string'||SHARE_UNSAFE.has(key))invalidShare();
|
|
20
|
+
const field=fields[key];
|
|
21
|
+
if(!Object.hasOwn(field,'value')||(!field.enumerable&&!(array&&key==='length')))invalidShare();
|
|
22
|
+
if(array&&key!=='length'&&!/^(0|[1-9]\d*)$/.test(key))invalidShare();
|
|
23
|
+
if(key!=='length'||!array)shareData(field.value,depth+1,budget);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function shareRecord(value,keys){
|
|
27
|
+
if(!value||typeof value!=='object'||Array.isArray(value))invalidShare();
|
|
28
|
+
if(keys&&(Object.keys(value).length!==keys.length||keys.some(key=>!Object.hasOwn(value,key))))invalidShare();
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
31
|
+
function shareList(value,known){
|
|
32
|
+
if(!Array.isArray(value)||value.length>known.length||new Set(value).size!==value.length||value.some(id=>typeof id!=='string'||!known.includes(id)))invalidShare();
|
|
33
|
+
return [...value];
|
|
34
|
+
}
|
|
35
|
+
function shareRelease(release){
|
|
36
|
+
shareData(release);shareRecord(release);
|
|
37
|
+
if(typeof release.version!=='string'||!SHARE_VERSION.test(release.version))invalidShare();
|
|
38
|
+
const preferences=shareRecord(release.preferences),clients=Object.keys(shareRecord(preferences.clients));
|
|
39
|
+
const catalog=shareRecord(shareRecord(release.catalog).clients);
|
|
40
|
+
if(!clients.length||clients.some(id=>!Object.hasOwn(catalog,id)||!Array.isArray(catalog[id].efforts)||catalog[id].efforts.some(level=>typeof level!=='string')))invalidShare();
|
|
41
|
+
if(!Array.isArray(release.roles)||!release.roles.length||!Array.isArray(release.presets)||!release.presets.length)invalidShare();
|
|
42
|
+
const roles=new Map(),presets=new Map();
|
|
43
|
+
for(const role of release.roles){
|
|
44
|
+
shareRecord(role);if(typeof role.id!=='string'||!role.id||SHARE_UNSAFE.has(role.id)||roles.has(role.id)||!['code','light'].includes(role.kind))invalidShare();
|
|
45
|
+
roles.set(role.id,role);
|
|
46
|
+
}
|
|
47
|
+
for(const preset of release.presets){
|
|
48
|
+
shareRecord(preset);if(typeof preset.id!=='string'||!preset.id||preset.id==='custom'||SHARE_UNSAFE.has(preset.id)||presets.has(preset.id))invalidShare();
|
|
49
|
+
shareRecord(shareRecord(preset.preferences).clients);
|
|
50
|
+
if(preset.topModels!==undefined&&Object.values(shareRecord(preset.topModels)).some(model=>typeof model!=='string'))invalidShare();
|
|
51
|
+
presets.set(preset.id,preset);
|
|
52
|
+
}
|
|
53
|
+
if(!presets.has(release.preset))invalidShare();
|
|
54
|
+
return {clients,roles,presets,catalog};
|
|
55
|
+
}
|
|
56
|
+
function shareChoice(choice,id,metadata){
|
|
57
|
+
shareRecord(choice,['model','effort']);
|
|
58
|
+
if(typeof choice.model!=='string'||choice.model.length>128||(choice.effort!==null&&(['agy','grok'].includes(id)||!metadata.catalog[id].efforts.includes(choice.effort))))invalidShare();
|
|
59
|
+
return {model:choice.model,effort:choice.effort};
|
|
60
|
+
}
|
|
61
|
+
function sharePayload(payload,release,metadata){
|
|
62
|
+
shareRecord(payload);const legacy=payload.v===1;
|
|
63
|
+
if(!legacy&&payload.v!==2)invalidShare();
|
|
64
|
+
shareRecord(payload,legacy?['v','release','roles','tools','scope','maxConcurrent']:['v','release','roles','tools','scope']);
|
|
65
|
+
if(typeof payload.release!=='string'||payload.release.length>128||!SHARE_VERSION.test(payload.release))invalidShare();
|
|
66
|
+
if(legacy&&(!Number.isInteger(payload.maxConcurrent)||payload.maxConcurrent<1||payload.maxConcurrent>32))invalidShare();
|
|
67
|
+
if(payload.release!==release.version){
|
|
68
|
+
// Validate the wire shape without imposing today's models or preset values on
|
|
69
|
+
// an older release. Invalid links must never turn into navigation.
|
|
70
|
+
const identifier=value=>typeof value==='string'&&/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(value);
|
|
71
|
+
if(!Array.isArray(payload.roles)||payload.roles.length>1000||payload.roles.some(role=>!identifier(role)))invalidShare();
|
|
72
|
+
const roles=shareList(payload.roles,payload.roles),ids=[];
|
|
73
|
+
if(!Array.isArray(payload.tools)||payload.tools.length>32)invalidShare();
|
|
74
|
+
for(const tool of payload.tools){
|
|
75
|
+
shareRecord(tool,legacy?['id','mode','base','choices']:['id','mode','base','choices','maxConcurrent']);if(!legacy&&(!Number.isInteger(tool.maxConcurrent)||tool.maxConcurrent<1||tool.maxConcurrent>32))invalidShare();if(!identifier(tool.id)||ids.includes(tool.id)||!identifier(tool.base)||(tool.mode!==null&&!identifier(tool.mode)))invalidShare();ids.push(tool.id);
|
|
76
|
+
shareRecord(tool.choices,roles);for(const role of roles){const choice=shareRecord(tool.choices[role],['model','effort']);if(typeof choice.model!=='string'||choice.model.length>128||(choice.effort!==null&&(typeof choice.effort!=='string'||choice.effort.length>32)))invalidShare();}
|
|
77
|
+
}
|
|
78
|
+
if(payload.scope!==null&&(typeof payload.scope!=='string'||(payload.scope==='all'?ids.length<2:!ids.includes(payload.scope))))invalidShare();
|
|
79
|
+
throw Object.assign(new Error('This link uses a different release.'),{code:'SHARE_RELEASE_MISMATCH',releaseVersion:payload.release});
|
|
80
|
+
}
|
|
81
|
+
const roles=shareList(payload.roles,[...metadata.roles.keys()]);
|
|
82
|
+
if(!Array.isArray(payload.tools)||payload.tools.length>metadata.clients.length)invalidShare();
|
|
83
|
+
const selected=[];
|
|
84
|
+
const tools=payload.tools.map(tool=>{
|
|
85
|
+
shareRecord(tool,legacy?['id','mode','base','choices']:['id','mode','base','choices','maxConcurrent']);if(!legacy&&(!Number.isInteger(tool.maxConcurrent)||tool.maxConcurrent<1||tool.maxConcurrent>32))invalidShare();
|
|
86
|
+
const {id,mode,base}=tool;
|
|
87
|
+
if(!metadata.clients.includes(id)||selected.includes(id)||!metadata.presets.has(base)||(mode!==null&&mode!=='custom'&&!metadata.presets.has(mode)))invalidShare();
|
|
88
|
+
selected.push(id);shareRecord(tool.choices,roles);
|
|
89
|
+
const choices=Object.fromEntries(roles.map(role=>[role,shareChoice(tool.choices[role],id,metadata)]));
|
|
90
|
+
if(mode!==null&&mode!=='custom'){
|
|
91
|
+
if(mode!==base)invalidShare();
|
|
92
|
+
const named=metadata.presets.get(mode),preset=named.preferences,clients=preset.clients;
|
|
93
|
+
if(!legacy&&tool.maxConcurrent!==(clients[id]?.maxConcurrent??preset.maxConcurrent))invalidShare();
|
|
94
|
+
if(!Object.hasOwn(clients,id))invalidShare();
|
|
95
|
+
const preferences=shareRecord(clients[id]),own=role=>preferences.roles&&Object.hasOwn(preferences.roles,role);
|
|
96
|
+
// A preset's optional top model replaces every shared code role that has no role-specific choice, or none of them.
|
|
97
|
+
const top=named.topModels&&Object.hasOwn(named.topModels,id)?named.topModels[id]:null;
|
|
98
|
+
const topRoles=roles.filter(role=>metadata.roles.get(role).kind==='code'&&!own(role)),topped=top!==null&&topRoles.some(role=>choices[role].model===top);
|
|
99
|
+
for(const role of roles){
|
|
100
|
+
const expected=own(role)?preferences.roles[role]:preferences[metadata.roles.get(role).kind];
|
|
101
|
+
const choice=shareChoice(expected,id,metadata);if(topped&&topRoles.includes(role))choice.model=top;
|
|
102
|
+
if(choice.model!==choices[role].model||choice.effort!==choices[role].effort)invalidShare();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return {id,mode,base,choices,...(legacy?{}:{maxConcurrent:tool.maxConcurrent})};
|
|
106
|
+
});
|
|
107
|
+
const scope=payload.scope;
|
|
108
|
+
if(scope!==null&&(typeof scope!=='string'||(scope==='all'?tools.length<2:!selected.includes(scope))))invalidShare();
|
|
109
|
+
return {v:payload.v,release:payload.release,roles,tools,scope,...(legacy?{maxConcurrent:payload.maxConcurrent}:{})};
|
|
110
|
+
}
|
|
111
|
+
function shareBase64(bytes){return btoa(Array.from(bytes,byte=>String.fromCharCode(byte)).join('')).replaceAll('+','-').replaceAll('/','_').replace(/=+$/,'');}
|
|
112
|
+
function shareJson(text){
|
|
113
|
+
const payload=JSON.parse(text),stack=[];
|
|
114
|
+
// JSON.parse validates syntax; this small token pass preserves duplicate-key
|
|
115
|
+
// evidence that JSON.parse would otherwise discard, including escaped names.
|
|
116
|
+
const tokens=text.match(/"(?:[^"\\]|\\.)*"|[{}\[\]:,]/g)??[];
|
|
117
|
+
for(let at=0;at<tokens.length;at++){
|
|
118
|
+
const token=tokens[at];
|
|
119
|
+
if(token==='{')stack.push(new Set());
|
|
120
|
+
else if(token==='[')stack.push(null);
|
|
121
|
+
else if(token==='}'||token===']')stack.pop();
|
|
122
|
+
else if(token[0]==='"'&&tokens[at+1]===':'){
|
|
123
|
+
const key=JSON.parse(token),keys=stack.at(-1);
|
|
124
|
+
if(!keys||keys.has(key)||SHARE_UNSAFE.has(key))invalidShare();keys.add(key);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return payload;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function encodeFormState(snapshot,release){
|
|
131
|
+
try{
|
|
132
|
+
shareData(snapshot);shareRecord(snapshot);
|
|
133
|
+
const metadata=shareRelease(release),state=shareRecord(snapshot.state),profiles=shareRecord(state.profiles),jobs=shareRecord(state.jobs);
|
|
134
|
+
const roles=shareList(snapshot.helpers,[...metadata.roles.keys()]),selected=shareList(snapshot.pickedTools,metadata.clients);
|
|
135
|
+
const modern=Object.values(profiles).some(profile=>Object.hasOwn(profile,'maxConcurrent'));
|
|
136
|
+
const tools=selected.map(id=>{
|
|
137
|
+
if(!Object.hasOwn(profiles,id)||!Object.hasOwn(jobs,id))invalidShare();
|
|
138
|
+
const profile=shareRecord(profiles[id]),choices=shareRecord(jobs[id]);
|
|
139
|
+
if(typeof profile.custom!=='boolean'||(profile.modeSelected!==undefined&&typeof profile.modeSelected!=='boolean'))invalidShare();
|
|
140
|
+
return {id,...(modern?{maxConcurrent:Object.hasOwn(profile,'maxConcurrent')?profile.maxConcurrent:state.maxConcurrent}:{}),mode:profile.modeSelected===false?null:profile.custom?'custom':profile.presetId,base:profile.presetId,choices:Object.fromEntries(roles.map(role=>{
|
|
141
|
+
if(!Object.hasOwn(choices,role))invalidShare();return [role,shareChoice(choices[role],id,metadata)];
|
|
142
|
+
}))};
|
|
143
|
+
});
|
|
144
|
+
const payload=sharePayload({v:modern?2:1,release:release.version,roles,tools,scope:snapshot.selectedScope,...(modern?{}:{maxConcurrent:state.maxConcurrent})},release,metadata);
|
|
145
|
+
if(!roles.length&&!tools.length)return '';
|
|
146
|
+
const bytes=new TextEncoder().encode(JSON.stringify(payload));if(bytes.length>SHARE_BYTES)invalidShare();
|
|
147
|
+
const token=shareBase64(bytes);if(token.length>SHARE_TOKEN)invalidShare();return '#setup='+token;
|
|
148
|
+
}catch(error){if(error?.code==='INVALID_SHARE_LINK'||error?.code==='SHARE_RELEASE_MISMATCH')throw error;invalidShare();}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function decodeFormState(fragment,release){
|
|
152
|
+
try{
|
|
153
|
+
if(typeof fragment!=='string')invalidShare();
|
|
154
|
+
if(!fragment.startsWith('#setup='))return null;
|
|
155
|
+
const token=fragment.slice(7);
|
|
156
|
+
if(!token.length||token.length>SHARE_TOKEN||!/^[A-Za-z0-9_-]+$/.test(token)||token.length%4===1)invalidShare();
|
|
157
|
+
const binary=atob(token.replaceAll('-','+').replaceAll('_','/'));
|
|
158
|
+
if(binary.length>SHARE_BYTES)invalidShare();
|
|
159
|
+
const bytes=Uint8Array.from(binary,char=>char.charCodeAt(0));if(shareBase64(bytes)!==token)invalidShare();
|
|
160
|
+
const payload=shareJson(new TextDecoder('utf-8',{fatal:true}).decode(bytes));shareData(payload);
|
|
161
|
+
return sharePayload(payload,release,shareRelease(release));
|
|
162
|
+
}catch(error){if(error?.code==='INVALID_SHARE_LINK'||error?.code==='SHARE_RELEASE_MISMATCH')throw error;invalidShare();}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function archivedReleaseUrl(error,href,hostedReleases=[]){
|
|
166
|
+
if(error?.code!=='SHARE_RELEASE_MISMATCH'||typeof error.releaseVersion!=='string'||error.releaseVersion.length>128||!SHARE_VERSION.test(error.releaseVersion)||!Array.isArray(hostedReleases)||!hostedReleases.includes(error.releaseVersion))return null;
|
|
167
|
+
try{
|
|
168
|
+
const current=new URL(href);if(!['http:','https:'].includes(current.protocol)||current.username||current.password||current.pathname!=='/'||!current.hash.startsWith('#setup='))return null;
|
|
169
|
+
const target=new URL(`/v/${error.releaseVersion}/`,current.origin);target.hash=current.hash;
|
|
170
|
+
return target.origin===current.origin?target.href:null;
|
|
171
|
+
}catch{return null;}
|
|
172
|
+
}
|
package/src/toml.mjs
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { parseForESLint, getStaticTOMLValue } from 'toml-eslint-parser';
|
|
2
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
3
|
+
|
|
4
|
+
function fail(code) { throw Object.assign(new Error(code), {code}); }
|
|
5
|
+
function ast(text) {
|
|
6
|
+
if (typeof text !== 'string' || Buffer.byteLength(text) > 1024 * 1024) fail('INVALID_TOML');
|
|
7
|
+
try { return parseForESLint(text, {tomlVersion:'1.0'}).ast; }
|
|
8
|
+
catch { fail('INVALID_TOML'); }
|
|
9
|
+
}
|
|
10
|
+
export function parseToml(text) { return getStaticTOMLValue(ast(text)); }
|
|
11
|
+
const keys = key => key.keys.map(k => k.type === 'TOMLBare' ? k.name : k.value);
|
|
12
|
+
const same = (a, b) => isDeepStrictEqual(a, b);
|
|
13
|
+
const literal = value => {
|
|
14
|
+
if (!['string', 'number', 'boolean'].includes(typeof value) || (typeof value === 'number' && !Number.isSafeInteger(value))) fail('UNSUPPORTED_TOML_VALUE');
|
|
15
|
+
return JSON.stringify(value);
|
|
16
|
+
};
|
|
17
|
+
const keyText = key => /^[A-Za-z0-9_-]+$/.test(key) ? key : JSON.stringify(key);
|
|
18
|
+
function locate(tree, target) {
|
|
19
|
+
for (const node of tree.body[0].body) {
|
|
20
|
+
const table = node.type === 'TOMLTable' ? node : null;
|
|
21
|
+
for (const kv of table ? table.body : [node]) {
|
|
22
|
+
if (kv.type !== 'TOMLKeyValue') continue;
|
|
23
|
+
const full = [...(table?.resolvedKey ?? []), ...keys(kv.key)];
|
|
24
|
+
if (same(full, target)) return kv;
|
|
25
|
+
if (full.length < target.length && same(full, target.slice(0, full.length))) fail('UNSUPPORTED_TOML_STRUCTURE');
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
function replace(text, start, end, value) { return text.slice(0, start) + value + text.slice(end); }
|
|
31
|
+
function newline(text) { return text.includes('\r\n') ? '\r\n' : '\n'; }
|
|
32
|
+
function endLine(text, position) { const i = text.indexOf('\n', position); return i === -1 ? text.length : i + 1; }
|
|
33
|
+
function removeStatement(text, node) {
|
|
34
|
+
const startLine = text.lastIndexOf('\n', node.range[0] - 1) + 1;
|
|
35
|
+
const start = /^\s*$/.test(text.slice(startLine, node.range[0])) ? startLine : node.range[0];
|
|
36
|
+
const end = endLine(text, node.range[1]);
|
|
37
|
+
const tail = text.slice(node.range[1], end);
|
|
38
|
+
return replace(text, start, /^\s*$/.test(tail) ? end : node.range[1], '');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function setTomlValue(text, target, value) {
|
|
42
|
+
const tree = ast(text);
|
|
43
|
+
const found = locate(tree, target);
|
|
44
|
+
const after = literal(value);
|
|
45
|
+
const ownership = {kind:'scalar',path:target,before:found ? text.slice(...found.value.range) : null,after};
|
|
46
|
+
if (found) return {text:replace(text, ...found.value.range, after),ownership};
|
|
47
|
+
const tablePath = target.slice(0, -1);
|
|
48
|
+
const tables = tree.body[0].body.filter(n => n.type === 'TOMLTable');
|
|
49
|
+
// Arrays of tables and inline/dotted parent definitions cannot be extended safely here.
|
|
50
|
+
if (tables.some(n => n.kind === 'array' && same(n.resolvedKey.slice(0, tablePath.length), tablePath))) fail('UNSUPPORTED_TOML_STRUCTURE');
|
|
51
|
+
const table = tables.find(n => n.kind === 'standard' && same(n.resolvedKey, tablePath));
|
|
52
|
+
const nl = newline(text);
|
|
53
|
+
const entry = `${keyText(target.at(-1))} = ${after}${nl}`;
|
|
54
|
+
if (table) {
|
|
55
|
+
const at = endLine(text, table.key.range[1]);
|
|
56
|
+
const separator = at === text.length && !text.endsWith('\n') ? nl : '';
|
|
57
|
+
text = replace(text, at, at, separator + entry);
|
|
58
|
+
} else if (tablePath.length === 0) {
|
|
59
|
+
text = entry + text;
|
|
60
|
+
} else {
|
|
61
|
+
const prefix = text.length && !text.endsWith('\n') ? nl : '';
|
|
62
|
+
text += `${prefix}${nl}[${tablePath.map(keyText).join('.')}]${nl}${entry}`;
|
|
63
|
+
ownership.addedTable = tablePath;
|
|
64
|
+
}
|
|
65
|
+
ast(text);
|
|
66
|
+
return {text,ownership};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function restoreTomlValue(text, ownership) {
|
|
70
|
+
const tree = ast(text);
|
|
71
|
+
const found = locate(tree, ownership.path);
|
|
72
|
+
if (ownership.kind === 'removed') {
|
|
73
|
+
if (found) fail('OWNED_DRIFT');
|
|
74
|
+
if (ownership.before === null) return text;
|
|
75
|
+
const before = parseToml(`value = ${ownership.before}\n`).value;
|
|
76
|
+
return setTomlValue(text,ownership.path,before).text;
|
|
77
|
+
}
|
|
78
|
+
if (!found || text.slice(...found.value.range) !== ownership.after) fail('OWNED_DRIFT');
|
|
79
|
+
if (ownership.before !== null) return replace(text, ...found.value.range, ownership.before);
|
|
80
|
+
text = removeStatement(text, found);
|
|
81
|
+
if (ownership.addedTable) {
|
|
82
|
+
const table = ast(text).body[0].body.find(n => n.type === 'TOMLTable' && n.kind === 'standard' && same(n.resolvedKey, ownership.addedTable));
|
|
83
|
+
if (table && table.body.length === 0) text = removeStatement(text, table);
|
|
84
|
+
}
|
|
85
|
+
ast(text);
|
|
86
|
+
return text;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function removeTomlValue(text,target) {
|
|
90
|
+
const tree=ast(text),found=locate(tree,target);
|
|
91
|
+
if(!found)return {text,ownership:{kind:'removed',path:target,before:null}};
|
|
92
|
+
const ownership={kind:'removed',path:target,before:text.slice(...found.value.range)};
|
|
93
|
+
const result=removeStatement(text,found);
|
|
94
|
+
ast(result);
|
|
95
|
+
return {text:result,ownership};
|
|
96
|
+
}
|