@aragon-agent/cli 0.4.3
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/CHANGELOG.md +292 -0
- package/README.md +558 -0
- package/dist/agent/coalesce.d.ts +14 -0
- package/dist/agent/coalesce.js +41 -0
- package/dist/agent/controller.d.ts +132 -0
- package/dist/agent/controller.js +332 -0
- package/dist/agent/headless.d.ts +22 -0
- package/dist/agent/headless.js +74 -0
- package/dist/agent/reducer.d.ts +181 -0
- package/dist/agent/reducer.js +420 -0
- package/dist/agent/system-prompt.d.ts +30 -0
- package/dist/agent/system-prompt.js +58 -0
- package/dist/agent/usage.d.ts +13 -0
- package/dist/agent/usage.js +41 -0
- package/dist/cli.d.ts +11 -0
- package/dist/cli.js +482 -0
- package/dist/commands/builtins.d.ts +16 -0
- package/dist/commands/builtins.js +223 -0
- package/dist/commands/registry.d.ts +48 -0
- package/dist/commands/registry.js +45 -0
- package/dist/commands/skills.d.ts +22 -0
- package/dist/commands/skills.js +625 -0
- package/dist/config/app-paths.d.ts +11 -0
- package/dist/config/app-paths.js +16 -0
- package/dist/config/env.d.ts +28 -0
- package/dist/config/env.js +92 -0
- package/dist/config/load.d.ts +48 -0
- package/dist/config/load.js +158 -0
- package/dist/config/migrate-legacy-state.d.ts +78 -0
- package/dist/config/migrate-legacy-state.js +181 -0
- package/dist/config/schema.d.ts +249 -0
- package/dist/config/schema.js +247 -0
- package/dist/config/store.d.ts +44 -0
- package/dist/config/store.js +126 -0
- package/dist/input/keymap.d.ts +20 -0
- package/dist/input/keymap.js +85 -0
- package/dist/session/persist.d.ts +24 -0
- package/dist/session/persist.js +51 -0
- package/dist/skills/archive.d.ts +41 -0
- package/dist/skills/archive.js +152 -0
- package/dist/skills/cli-commands.d.ts +52 -0
- package/dist/skills/cli-commands.js +502 -0
- package/dist/skills/fetch-source.d.ts +104 -0
- package/dist/skills/fetch-source.js +448 -0
- package/dist/skills/installer.d.ts +117 -0
- package/dist/skills/installer.js +414 -0
- package/dist/skills/lock.d.ts +75 -0
- package/dist/skills/lock.js +223 -0
- package/dist/skills/manifest.d.ts +47 -0
- package/dist/skills/manifest.js +95 -0
- package/dist/skills/node-host.d.ts +23 -0
- package/dist/skills/node-host.js +59 -0
- package/dist/skills/paths.d.ts +61 -0
- package/dist/skills/paths.js +107 -0
- package/dist/skills/service.d.ts +256 -0
- package/dist/skills/service.js +730 -0
- package/dist/skills/tools.d.ts +35 -0
- package/dist/skills/tools.js +164 -0
- package/dist/skills/updater.d.ts +101 -0
- package/dist/skills/updater.js +330 -0
- package/dist/skills/usage.d.ts +72 -0
- package/dist/skills/usage.js +216 -0
- package/dist/tools/bash-tool.d.ts +12 -0
- package/dist/tools/bash-tool.js +131 -0
- package/dist/tools/diff.d.ts +15 -0
- package/dist/tools/diff.js +50 -0
- package/dist/tools/fs-tools.d.ts +16 -0
- package/dist/tools/fs-tools.js +215 -0
- package/dist/tools/index.d.ts +93 -0
- package/dist/tools/index.js +157 -0
- package/dist/tools/search-tools.d.ts +11 -0
- package/dist/tools/search-tools.js +225 -0
- package/dist/ui/App.d.ts +24 -0
- package/dist/ui/App.js +522 -0
- package/dist/ui/AutocompletePopup.d.ts +21 -0
- package/dist/ui/AutocompletePopup.js +19 -0
- package/dist/ui/Composer.d.ts +40 -0
- package/dist/ui/Composer.js +61 -0
- package/dist/ui/Header.d.ts +30 -0
- package/dist/ui/Header.js +25 -0
- package/dist/ui/Logo.d.ts +50 -0
- package/dist/ui/Logo.js +85 -0
- package/dist/ui/Markdown.d.ts +23 -0
- package/dist/ui/Markdown.js +122 -0
- package/dist/ui/PromptInput.d.ts +81 -0
- package/dist/ui/PromptInput.js +383 -0
- package/dist/ui/SessionOpener.d.ts +30 -0
- package/dist/ui/SessionOpener.js +12 -0
- package/dist/ui/StatusBar.d.ts +39 -0
- package/dist/ui/StatusBar.js +43 -0
- package/dist/ui/ToastStack.d.ts +24 -0
- package/dist/ui/ToastStack.js +38 -0
- package/dist/ui/Transcript.d.ts +62 -0
- package/dist/ui/Transcript.js +128 -0
- package/dist/ui/capabilities.d.ts +25 -0
- package/dist/ui/capabilities.js +59 -0
- package/dist/ui/console-bridge.d.ts +19 -0
- package/dist/ui/console-bridge.js +52 -0
- package/dist/ui/density.d.ts +26 -0
- package/dist/ui/density.js +34 -0
- package/dist/ui/entries/AssistantEntry.d.ts +23 -0
- package/dist/ui/entries/AssistantEntry.js +13 -0
- package/dist/ui/entries/EntryFrame.d.ts +23 -0
- package/dist/ui/entries/EntryFrame.js +6 -0
- package/dist/ui/entries/ToolCard.d.ts +38 -0
- package/dist/ui/entries/ToolCard.js +72 -0
- package/dist/ui/entries/ToolPreview.d.ts +57 -0
- package/dist/ui/entries/ToolPreview.js +144 -0
- package/dist/ui/entries/UserEntry.d.ts +14 -0
- package/dist/ui/entries/UserEntry.js +5 -0
- package/dist/ui/exit-snapshot.d.ts +27 -0
- package/dist/ui/exit-snapshot.js +26 -0
- package/dist/ui/gauge.d.ts +24 -0
- package/dist/ui/gauge.js +36 -0
- package/dist/ui/glyphs.d.ts +87 -0
- package/dist/ui/glyphs.js +127 -0
- package/dist/ui/gradient.d.ts +11 -0
- package/dist/ui/gradient.js +50 -0
- package/dist/ui/layout/AppShell.d.ts +24 -0
- package/dist/ui/layout/AppShell.js +9 -0
- package/dist/ui/layout/Gutter.d.ts +67 -0
- package/dist/ui/layout/Gutter.js +42 -0
- package/dist/ui/layout/OverlayFrame.d.ts +62 -0
- package/dist/ui/layout/OverlayFrame.js +92 -0
- package/dist/ui/layout/ScrollViewport.d.ts +33 -0
- package/dist/ui/layout/ScrollViewport.js +71 -0
- package/dist/ui/layout/budget.d.ts +42 -0
- package/dist/ui/layout/budget.js +47 -0
- package/dist/ui/layout/frame.d.ts +58 -0
- package/dist/ui/layout/frame.js +75 -0
- package/dist/ui/layout/overlay-window.d.ts +43 -0
- package/dist/ui/layout/overlay-window.js +51 -0
- package/dist/ui/layout/scroll.d.ts +28 -0
- package/dist/ui/layout/scroll.js +76 -0
- package/dist/ui/layout/useTerminalSize.d.ts +13 -0
- package/dist/ui/layout/useTerminalSize.js +50 -0
- package/dist/ui/markdown-blocks.d.ts +62 -0
- package/dist/ui/markdown-blocks.js +120 -0
- package/dist/ui/overlays/ConfirmDialog.d.ts +30 -0
- package/dist/ui/overlays/ConfirmDialog.js +25 -0
- package/dist/ui/overlays/HelpOverlay.d.ts +17 -0
- package/dist/ui/overlays/HelpOverlay.js +60 -0
- package/dist/ui/overlays/ModelPicker.d.ts +29 -0
- package/dist/ui/overlays/ModelPicker.js +29 -0
- package/dist/ui/overlays/SettingsScreen.d.ts +37 -0
- package/dist/ui/overlays/SettingsScreen.js +106 -0
- package/dist/ui/palettes.d.ts +79 -0
- package/dist/ui/palettes.js +115 -0
- package/dist/ui/screen.d.ts +27 -0
- package/dist/ui/screen.js +57 -0
- package/dist/ui/theme.d.ts +78 -0
- package/dist/ui/theme.js +127 -0
- package/dist/ui/transcript-text.d.ts +27 -0
- package/dist/ui/transcript-text.js +110 -0
- package/package.json +73 -0
- package/skills/skill-creator/SKILL.md +96 -0
|
@@ -0,0 +1,730 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SkillService — discovery, lazy loading, prompt-block assembly, and the
|
|
3
|
+
* lifecycle surface the UI and the agent tools call into (spec §12.2).
|
|
4
|
+
*
|
|
5
|
+
* Everything mutating (install / create / remove) delegates to `installer.ts`;
|
|
6
|
+
* this file owns the read side and the registry.
|
|
7
|
+
*/
|
|
8
|
+
import { rmSync, statSync } from 'node:fs';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { SkillRegistry, computeToolPolicy, parseFrontmatter, renderSkillCatalog, } from '@aragon-agent/core';
|
|
11
|
+
import { SKILL_FILES_MAX, SKILL_MD_MAX_BYTES, SKILL_SCAN_MAX_DIRS, SKILL_SCAN_SOFT_TIMEOUT_MS, STAGING_TTL_MS, TOOL_POLICY_DENY_ESCALATE_AT, normalizeFrontmatter, renderAlwaysSkills, renderDenyEscalation, renderDenyEscalationNotice, validateSkillFrontmatter, } from '@aragon-agent/core/skills';
|
|
12
|
+
import { SKILL_TOOL_FLOOR } from '../tools/index.js';
|
|
13
|
+
import { currentPlatform } from './node-host.js';
|
|
14
|
+
import { MANIFEST_FILENAME, readManifest, sha256Buffer, sha256File } from './manifest.js';
|
|
15
|
+
import { isDirectory, normalizeTrustPath, resolveSkillRoots, getStagingDir, } from './paths.js';
|
|
16
|
+
import { USAGE_MAX_ENTRIES, loadUsage, pruneUsage, recordUse } from './usage.js';
|
|
17
|
+
const ENTRY_FILE = 'SKILL.md';
|
|
18
|
+
/**
|
|
19
|
+
* Re-exported from `manifest.ts` rather than spelled out again. The two copies
|
|
20
|
+
* of this literal were already a drift hazard; the rename made it concrete by
|
|
21
|
+
* requiring both to change together (spec §6.3).
|
|
22
|
+
*/
|
|
23
|
+
const MANIFEST_FILE = MANIFEST_FILENAME;
|
|
24
|
+
const BUNDLED_FILE_DEPTH = 3;
|
|
25
|
+
/** v1 left backups inside the skills root; skip them so they cannot resurrect (P1-6). */
|
|
26
|
+
const BAK_DIR_PATTERN = /\.bak-\d+$/;
|
|
27
|
+
export class SkillService {
|
|
28
|
+
deps;
|
|
29
|
+
registry = new SkillRegistry();
|
|
30
|
+
config;
|
|
31
|
+
lastErrors = [];
|
|
32
|
+
pendingTrust = [];
|
|
33
|
+
/** Empty when `skills.usageTracking` is off — which is exactly invariant I-A1. */
|
|
34
|
+
usage = {};
|
|
35
|
+
/** The integrity warning is worth saying once a session, not once a rescan. */
|
|
36
|
+
integrityWarned = false;
|
|
37
|
+
/**
|
|
38
|
+
* `/skills policy <mode>` writes here. `null` = this session has no opinion.
|
|
39
|
+
* NEVER persisted — the config file is written separately by the same command.
|
|
40
|
+
*/
|
|
41
|
+
sessionToolPolicy = null;
|
|
42
|
+
/**
|
|
43
|
+
* The previous decision, used to notice when the ceiling WIDENS (§5.6e).
|
|
44
|
+
*
|
|
45
|
+
* A skill can tell the model to load a second skill, and because the ceiling
|
|
46
|
+
* is a union with `skill` permanently in the floor, that works (D-G17). The
|
|
47
|
+
* path is not blocked — every way of blocking it costs more than it saves —
|
|
48
|
+
* so instead it leaves a trace. This pair of fields is that trace's memory.
|
|
49
|
+
*/
|
|
50
|
+
lastDecisionAllowed = null;
|
|
51
|
+
lastDecisionSources = new Set();
|
|
52
|
+
constructor(deps) {
|
|
53
|
+
this.deps = deps;
|
|
54
|
+
this.config = deps.config;
|
|
55
|
+
if (this.config.usageTracking)
|
|
56
|
+
this.usage = loadUsage();
|
|
57
|
+
}
|
|
58
|
+
// -----------------------------------------------------------------------
|
|
59
|
+
// Discovery
|
|
60
|
+
// -----------------------------------------------------------------------
|
|
61
|
+
/**
|
|
62
|
+
* Scan all four roots in ascending precedence and rebuild the registry.
|
|
63
|
+
*
|
|
64
|
+
* One unreadable directory must never sink the whole pass: every root and
|
|
65
|
+
* every candidate is wrapped, and partial results are returned alongside the
|
|
66
|
+
* errors so `/skills` can show both what loaded and what did not.
|
|
67
|
+
*/
|
|
68
|
+
discover() {
|
|
69
|
+
const errors = [];
|
|
70
|
+
const pendingTrust = [];
|
|
71
|
+
const records = [];
|
|
72
|
+
const startedAt = Date.now();
|
|
73
|
+
this.reclaimStaleStaging();
|
|
74
|
+
for (const root of resolveSkillRoots(this.deps.getCwd(), this.config.projectDirs)) {
|
|
75
|
+
if (Date.now() - startedAt > SKILL_SCAN_SOFT_TIMEOUT_MS) {
|
|
76
|
+
errors.push(`skill scan exceeded ${SKILL_SCAN_SOFT_TIMEOUT_MS}ms; remaining roots skipped`);
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
if (!isDirectory(root.dir))
|
|
80
|
+
continue;
|
|
81
|
+
if (root.scope === 'project' && !this.isTrusted(root.dir)) {
|
|
82
|
+
pendingTrust.push(root.dir);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
records.push(...this.scanRoot(root, errors));
|
|
86
|
+
}
|
|
87
|
+
this.registry.replaceAll(records);
|
|
88
|
+
for (const name of this.config.disabled)
|
|
89
|
+
this.registry.setDisabled(name, true);
|
|
90
|
+
this.lastErrors = errors;
|
|
91
|
+
this.pendingTrust = pendingTrust;
|
|
92
|
+
this.pruneUsageIfOverCap();
|
|
93
|
+
this.reportIntegrity();
|
|
94
|
+
return { records: this.registry.list(), errors, pendingTrust };
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Enforce the usage-file cap (§9.2) — and ONLY when it is actually exceeded.
|
|
98
|
+
*
|
|
99
|
+
* `pruneUsage()` drops every counter whose skill is absent from the registry,
|
|
100
|
+
* and the registry is CWD-DEPENDENT: it holds the `project` skills of
|
|
101
|
+
* whichever directory this process happens to be running in. Calling it on
|
|
102
|
+
* every scan would therefore make a session in repo B silently erase the
|
|
103
|
+
* usage history of repo A — and a root that was momentarily unreadable would
|
|
104
|
+
* erase all of it. Counters for uninstalled skills are worth a few hundred
|
|
105
|
+
* bytes; the ranking signal they would take with them is not.
|
|
106
|
+
*/
|
|
107
|
+
pruneUsageIfOverCap() {
|
|
108
|
+
if (!this.config.usageTracking)
|
|
109
|
+
return;
|
|
110
|
+
if (Object.keys(this.usage).length <= USAGE_MAX_ENTRIES)
|
|
111
|
+
return;
|
|
112
|
+
pruneUsage(this.registry.names());
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Tell the user once per session which skills no longer match what they
|
|
116
|
+
* approved (`warn` mode only — `strict` already refuses them outright, and
|
|
117
|
+
* repeating the notice on every rescan is how a warning becomes wallpaper).
|
|
118
|
+
*/
|
|
119
|
+
reportIntegrity() {
|
|
120
|
+
if (this.config.integrity !== 'warn' || this.integrityWarned)
|
|
121
|
+
return;
|
|
122
|
+
const modified = this.registry
|
|
123
|
+
.list()
|
|
124
|
+
.filter((r) => r.integrity === 'modified')
|
|
125
|
+
.map((r) => r.name);
|
|
126
|
+
if (modified.length === 0)
|
|
127
|
+
return;
|
|
128
|
+
this.integrityWarned = true;
|
|
129
|
+
this.deps.notify?.('warn', `SKILL.md changed since install: ${modified.join(', ')}. ` +
|
|
130
|
+
'Run "aragon skills doctor" for details, or "aragon skills update <name> --force" to restore.');
|
|
131
|
+
}
|
|
132
|
+
/** Scan one root. Returns the records it yielded; appends to `errors` in place. */
|
|
133
|
+
scanRoot(root, errors) {
|
|
134
|
+
const out = [];
|
|
135
|
+
let entries;
|
|
136
|
+
try {
|
|
137
|
+
entries = this.deps.host.listDir(root.dir);
|
|
138
|
+
}
|
|
139
|
+
catch (err) {
|
|
140
|
+
errors.push(`cannot read skills root ${root.dir}: ${message(err)}`);
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
const dirs = entries
|
|
144
|
+
.filter((e) => e.isDirectory)
|
|
145
|
+
.filter((e) => !e.name.startsWith('.'))
|
|
146
|
+
.filter((e) => !BAK_DIR_PATTERN.test(e.name))
|
|
147
|
+
// Deterministic order: the same-name tie-break below must not depend on
|
|
148
|
+
// the platform's readdir sequence (§5.1 / §6.2 step 4).
|
|
149
|
+
.sort((a, b) => a.name.localeCompare(b.name, 'en'))
|
|
150
|
+
.slice(0, SKILL_SCAN_MAX_DIRS);
|
|
151
|
+
const seenInRoot = new Set();
|
|
152
|
+
for (const entry of dirs) {
|
|
153
|
+
const dir = join(root.dir, entry.name);
|
|
154
|
+
try {
|
|
155
|
+
const record = this.readSkillDir(dir, entry.name, root);
|
|
156
|
+
if (!record)
|
|
157
|
+
continue;
|
|
158
|
+
if (seenInRoot.has(record.name)) {
|
|
159
|
+
errors.push(`duplicate skill name "${record.name}" in ${root.dir}; keeping the first directory in lexical order`);
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
seenInRoot.add(record.name);
|
|
163
|
+
out.push(record);
|
|
164
|
+
}
|
|
165
|
+
catch (err) {
|
|
166
|
+
errors.push(`skipping ${dir}: ${message(err)}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
/** Build a record from a candidate directory, or `null` when it is not a skill. */
|
|
172
|
+
readSkillDir(dir, dirName, root) {
|
|
173
|
+
const entryPath = join(dir, ENTRY_FILE);
|
|
174
|
+
if (!this.deps.host.exists(entryPath))
|
|
175
|
+
return null;
|
|
176
|
+
const bytes = statSync(entryPath).size;
|
|
177
|
+
const issues = [];
|
|
178
|
+
let source = '';
|
|
179
|
+
let readOk = false;
|
|
180
|
+
if (bytes > SKILL_MD_MAX_BYTES) {
|
|
181
|
+
issues.push({
|
|
182
|
+
level: 'error',
|
|
183
|
+
code: 'SKILL_MD_TOO_LARGE',
|
|
184
|
+
message: `SKILL.md is ${bytes} bytes (max ${SKILL_MD_MAX_BYTES})`,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
source = this.deps.host.readTextFile(entryPath);
|
|
189
|
+
readOk = true;
|
|
190
|
+
}
|
|
191
|
+
const parsed = source.length > 0 ? parseFrontmatter(source) : null;
|
|
192
|
+
if (source.length > 0 && !parsed) {
|
|
193
|
+
issues.push({
|
|
194
|
+
level: 'error',
|
|
195
|
+
code: 'SKILL_FRONTMATTER_MISSING',
|
|
196
|
+
message: 'SKILL.md has no parseable YAML frontmatter block',
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
const data = parsed?.data ?? {};
|
|
200
|
+
issues.push(...validateSkillFrontmatter(data, dirName));
|
|
201
|
+
const frontmatter = normalizeFrontmatter(data, dirName);
|
|
202
|
+
const manifest = readManifest(join(dir, MANIFEST_FILE));
|
|
203
|
+
const integrity = readOk
|
|
204
|
+
? this.computeIntegrity(entryPath, source, manifest)
|
|
205
|
+
: 'unverified';
|
|
206
|
+
// `strict` turns a mismatch into a validation error, which is what removes
|
|
207
|
+
// the skill from the catalog, from the always-block, from `skill_find` and
|
|
208
|
+
// from the dynamic slash commands — all of which already key off `invalid`.
|
|
209
|
+
// Expressing it as an issue rather than four separate filters means a new
|
|
210
|
+
// consumer cannot forget to honour it.
|
|
211
|
+
if (integrity === 'modified' && this.config.integrity === 'strict') {
|
|
212
|
+
issues.push({
|
|
213
|
+
level: 'error',
|
|
214
|
+
code: 'SKILL_INTEGRITY_MISMATCH',
|
|
215
|
+
message: 'SKILL.md does not match the version that was approved at install time. ' +
|
|
216
|
+
'Run "aragon skills doctor" to inspect it, or "aragon skills update <name> --force" to restore it.',
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
return {
|
|
220
|
+
name: frontmatter.name,
|
|
221
|
+
description: frontmatter.description,
|
|
222
|
+
scope: root.scope,
|
|
223
|
+
dir,
|
|
224
|
+
entryPath,
|
|
225
|
+
frontmatter,
|
|
226
|
+
body: null,
|
|
227
|
+
files: null,
|
|
228
|
+
bytes,
|
|
229
|
+
disabled: false,
|
|
230
|
+
issues,
|
|
231
|
+
invalid: issues.some((i) => i.level === 'error'),
|
|
232
|
+
shadowed: [],
|
|
233
|
+
manifest,
|
|
234
|
+
writable: root.writable,
|
|
235
|
+
integrity,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Compare SKILL.md against the hash recorded at install time (§8.1).
|
|
240
|
+
*
|
|
241
|
+
* Costs no extra read on the happy path: the text is already in memory from
|
|
242
|
+
* the scan, and the manifest was already parsed.
|
|
243
|
+
*
|
|
244
|
+
* D-A12 — THE SECOND HASH IS NOT REDUNDANT. `readTextFile()` returns a
|
|
245
|
+
* DECODED string. Re-encoding it round-trips byte-for-byte only for
|
|
246
|
+
* well-formed UTF-8; a file containing an invalid sequence comes back with
|
|
247
|
+
* U+FFFD substituted, so the re-encoded bytes differ from what is on disk and
|
|
248
|
+
* an untouched file would be reported as tampered. A false alarm here is
|
|
249
|
+
* worse than no alarm at all — it teaches the user that this warning is noise,
|
|
250
|
+
* and it is the same warning that has to be believed on the day it is real.
|
|
251
|
+
*/
|
|
252
|
+
computeIntegrity(entryPath, sourceText, manifest) {
|
|
253
|
+
if (!manifest || this.config.integrity === 'off')
|
|
254
|
+
return 'unverified';
|
|
255
|
+
const entry = manifest.files.find((f) => f.path === ENTRY_FILE);
|
|
256
|
+
if (!entry)
|
|
257
|
+
return 'unverified';
|
|
258
|
+
if (sha256Buffer(Buffer.from(sourceText, 'utf-8')) === entry.sha256)
|
|
259
|
+
return 'ok';
|
|
260
|
+
try {
|
|
261
|
+
return sha256File(entryPath) === entry.sha256 ? 'ok' : 'modified';
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
return 'modified';
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
/** Reload from disk and notify the controller so the prompt is rebuilt. */
|
|
268
|
+
reload() {
|
|
269
|
+
const result = this.discover();
|
|
270
|
+
this.deps.onChange?.();
|
|
271
|
+
return result;
|
|
272
|
+
}
|
|
273
|
+
// -----------------------------------------------------------------------
|
|
274
|
+
// Read surface
|
|
275
|
+
// -----------------------------------------------------------------------
|
|
276
|
+
list() {
|
|
277
|
+
return this.registry.list();
|
|
278
|
+
}
|
|
279
|
+
get(name) {
|
|
280
|
+
return this.registry.get(name);
|
|
281
|
+
}
|
|
282
|
+
getRegistry() {
|
|
283
|
+
return this.registry;
|
|
284
|
+
}
|
|
285
|
+
errors() {
|
|
286
|
+
return this.lastErrors;
|
|
287
|
+
}
|
|
288
|
+
/** Project roots the trust gate is still holding back (§9.3). */
|
|
289
|
+
untrustedDirs() {
|
|
290
|
+
return this.pendingTrust;
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Level 2 load: SKILL.md body + the bundled-file listing.
|
|
294
|
+
* MAY THROW — see the `SkillHost` error contract; `skill`'s `execute()` and
|
|
295
|
+
* every UI caller wrap this.
|
|
296
|
+
*
|
|
297
|
+
* THE SINGLE USAGE MEASUREMENT POINT (F8). All three consumption paths — the
|
|
298
|
+
* `skill` tool, a `/<skill-name>` command, and `alwaysBlock()` — funnel
|
|
299
|
+
* through here, so one call site covers everything.
|
|
300
|
+
*
|
|
301
|
+
* `countUse: false` is MANDATORY for `alwaysBlock()`. An always-on skill is
|
|
302
|
+
* injected automatically on every single turn; counting that would pin it to
|
|
303
|
+
* the top of the ranking forever and drown out the signal the ranking exists
|
|
304
|
+
* to capture, which is what a human or a model actually CHOSE to reach for.
|
|
305
|
+
*/
|
|
306
|
+
loadBody(name, opts = {}) {
|
|
307
|
+
const record = this.registry.get(name);
|
|
308
|
+
if (!record)
|
|
309
|
+
throw new Error(`unknown skill "${name}"`);
|
|
310
|
+
const source = this.deps.host.readTextFile(record.entryPath);
|
|
311
|
+
const parsed = parseFrontmatter(source);
|
|
312
|
+
if (opts.countUse !== false && this.config.usageTracking)
|
|
313
|
+
recordUse(name);
|
|
314
|
+
return { body: parsed ? parsed.body : source, files: this.listSkillFiles(record.dir) };
|
|
315
|
+
}
|
|
316
|
+
/** Walk a skill directory for bundled resources (depth ≤ 3, dotfiles skipped). */
|
|
317
|
+
listSkillFiles(dir, prefix = '', depth = 0) {
|
|
318
|
+
if (depth >= BUNDLED_FILE_DEPTH)
|
|
319
|
+
return [];
|
|
320
|
+
let entries;
|
|
321
|
+
try {
|
|
322
|
+
entries = this.deps.host.listDir(dir);
|
|
323
|
+
}
|
|
324
|
+
catch {
|
|
325
|
+
return [];
|
|
326
|
+
}
|
|
327
|
+
const out = [];
|
|
328
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name, 'en'))) {
|
|
329
|
+
if (entry.name.startsWith('.'))
|
|
330
|
+
continue;
|
|
331
|
+
if (entry.name === ENTRY_FILE && prefix === '')
|
|
332
|
+
continue;
|
|
333
|
+
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
334
|
+
if (entry.isDirectory) {
|
|
335
|
+
out.push(...this.listSkillFiles(join(dir, entry.name), rel, depth + 1));
|
|
336
|
+
}
|
|
337
|
+
else {
|
|
338
|
+
out.push({ path: rel, bytes: entry.bytes });
|
|
339
|
+
}
|
|
340
|
+
if (out.length > SKILL_FILES_MAX * 2)
|
|
341
|
+
break;
|
|
342
|
+
}
|
|
343
|
+
return out;
|
|
344
|
+
}
|
|
345
|
+
// -----------------------------------------------------------------------
|
|
346
|
+
// Prompt blocks
|
|
347
|
+
// -----------------------------------------------------------------------
|
|
348
|
+
/** Level 1 catalog for the system prompt, or `''` when there is nothing to say. */
|
|
349
|
+
catalogBlock() {
|
|
350
|
+
if (!this.config.enabled)
|
|
351
|
+
return '';
|
|
352
|
+
return renderSkillCatalog(this.registry.list(), {
|
|
353
|
+
maxBytes: this.config.catalogMaxBytes,
|
|
354
|
+
// Omitted entirely when tracking is off, which is what makes the order
|
|
355
|
+
// degrade to the pre-ranking one byte for byte (I-A1).
|
|
356
|
+
...(this.config.usageTracking ? { usage: this.usage } : {}),
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* `activation: always` bodies plus anything forced with `--skill`.
|
|
361
|
+
* Returns `''` when empty so the caller can concatenate unconditionally
|
|
362
|
+
* without disturbing invariant I-S1.
|
|
363
|
+
*/
|
|
364
|
+
alwaysBlock() {
|
|
365
|
+
if (!this.config.enabled)
|
|
366
|
+
return '';
|
|
367
|
+
const records = this.registry.alwaysOn(this.deps.runtime.forcedSkills);
|
|
368
|
+
if (records.length === 0)
|
|
369
|
+
return '';
|
|
370
|
+
// Bodies are lazy by default (Level 2 is normally reached through the
|
|
371
|
+
// `skill` tool). Always-on skills are the one case where the body is needed
|
|
372
|
+
// at prompt-build time, so hydrate here — rendering the unhydrated record
|
|
373
|
+
// would emit a well-formed but EMPTY <skill> block, which looks like a
|
|
374
|
+
// working feature and teaches the model nothing.
|
|
375
|
+
const hydrated = [];
|
|
376
|
+
for (const record of records) {
|
|
377
|
+
if (record.body === null) {
|
|
378
|
+
try {
|
|
379
|
+
// countUse: false — see the `loadBody` contract. This injection is
|
|
380
|
+
// automatic, not a choice, and must not colour the ranking.
|
|
381
|
+
const loaded = this.loadBody(record.name, { countUse: false });
|
|
382
|
+
record.body = loaded.body;
|
|
383
|
+
record.files = loaded.files;
|
|
384
|
+
}
|
|
385
|
+
catch (err) {
|
|
386
|
+
this.lastErrors = [...this.lastErrors, `cannot load always-on skill ${record.name}: ${message(err)}`];
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
hydrated.push(record);
|
|
391
|
+
}
|
|
392
|
+
if (hydrated.length === 0)
|
|
393
|
+
return '';
|
|
394
|
+
// `platform` YES, `policy` NEVER (D-G18 / AC-G24).
|
|
395
|
+
//
|
|
396
|
+
// D-G9 keeps these skills out of the frame, so they impose no ceiling. Saying
|
|
397
|
+
// otherwise here would repeat "only these tools are permitted" in the system
|
|
398
|
+
// prompt on every single turn, for a restriction that does not exist, with
|
|
399
|
+
// nothing in the UI pointing at the frontmatter line responsible. Note the
|
|
400
|
+
// guarantee is structural, not a habit: `renderAlwaysSkills` has no `policy`
|
|
401
|
+
// field to pass, so this cannot regress by accident.
|
|
402
|
+
const block = renderAlwaysSkills(hydrated, {
|
|
403
|
+
bodyMaxBytes: this.config.bodyMaxBytes,
|
|
404
|
+
platform: this.platform(),
|
|
405
|
+
});
|
|
406
|
+
return block.length > 0 ? `\n${block}` : '';
|
|
407
|
+
}
|
|
408
|
+
/** Host shell family, forwarded into every Level 2 / Level 3 render (D-G14). */
|
|
409
|
+
platform() {
|
|
410
|
+
return currentPlatform();
|
|
411
|
+
}
|
|
412
|
+
// -----------------------------------------------------------------------
|
|
413
|
+
// Tool ceiling (§5)
|
|
414
|
+
// -----------------------------------------------------------------------
|
|
415
|
+
/**
|
|
416
|
+
* The mode actually in force, and where it came from.
|
|
417
|
+
*
|
|
418
|
+
* THREE LAYERS, not two (D-G19). `SkillsRuntimeOptions` is read-only for the
|
|
419
|
+
* life of the session, so with only `flag > config` a user who started with
|
|
420
|
+
* `--skill-tool-policy enforce` could run the very command the refusal text
|
|
421
|
+
* recommends — `/skills policy off` — and have precisely nothing happen, with
|
|
422
|
+
* no message either. An iteration about silent failures cannot ship one in its
|
|
423
|
+
* own escape hatch.
|
|
424
|
+
*/
|
|
425
|
+
effectiveToolPolicy() {
|
|
426
|
+
if (this.sessionToolPolicy !== null)
|
|
427
|
+
return { mode: this.sessionToolPolicy, from: 'session' };
|
|
428
|
+
const flag = this.deps.runtime.toolPolicy;
|
|
429
|
+
if (flag)
|
|
430
|
+
return { mode: flag, from: 'flag' };
|
|
431
|
+
return { mode: this.config.toolPolicy, from: 'config' };
|
|
432
|
+
}
|
|
433
|
+
setSessionToolPolicy(mode) {
|
|
434
|
+
this.sessionToolPolicy = mode;
|
|
435
|
+
}
|
|
436
|
+
/** `/skills policy <mode>` — session slot and config file, together. */
|
|
437
|
+
persistToolPolicy(mode) {
|
|
438
|
+
this.updateConfig({ toolPolicy: mode });
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* Compute the ceiling for right now. Constant-time when the frame is empty or
|
|
442
|
+
* the mode is `off`, which is the overwhelmingly common case on the hot path.
|
|
443
|
+
*/
|
|
444
|
+
toolPolicyDecision(getRegistered) {
|
|
445
|
+
const mode = this.effectiveToolPolicy().mode;
|
|
446
|
+
if (!this.config.enabled) {
|
|
447
|
+
return { mode: 'off', allowed: null, sources: [], sourceNames: [], ignored: [] };
|
|
448
|
+
}
|
|
449
|
+
const registered = getRegistered();
|
|
450
|
+
const decision = computeToolPolicy({
|
|
451
|
+
frame: this.registry.frameRecords(),
|
|
452
|
+
registered,
|
|
453
|
+
// Intersected with what is registered so `--no-skills`-style setups cannot
|
|
454
|
+
// advertise `skill` / `skill_find` in a permitted set that does not contain
|
|
455
|
+
// them. A phantom tool in that list is a small lie the model would act on.
|
|
456
|
+
floor: SKILL_TOOL_FLOOR.filter((n) => registered.includes(n)),
|
|
457
|
+
mode,
|
|
458
|
+
});
|
|
459
|
+
this.reportCeilingLifted();
|
|
460
|
+
this.reportFailOpen(decision);
|
|
461
|
+
this.reportCeilingWidened(decision);
|
|
462
|
+
return decision;
|
|
463
|
+
}
|
|
464
|
+
/** The renderer-facing slice of the current decision, or `undefined` for "say nothing". */
|
|
465
|
+
toolPolicyView(getRegistered) {
|
|
466
|
+
return toView(this.toolPolicyDecision(getRegistered));
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* The ceiling a QUEUED skill is about to impose — for the `/<skill-name>` path,
|
|
470
|
+
* whose message has not been submitted yet and whose frame is therefore still
|
|
471
|
+
* pending (§13.2).
|
|
472
|
+
*
|
|
473
|
+
* Computed from the pending set alone, which is exactly right for the common
|
|
474
|
+
* case (`controller.prompt()` replaces the frame with the pending one). When
|
|
475
|
+
* the agent happens to be running, `steer()` unions instead, so this can
|
|
476
|
+
* understate — it will never claim a tool is forbidden that is in fact allowed
|
|
477
|
+
* only because some other skill is also in the frame. Overstating would be the
|
|
478
|
+
* dangerous direction; a refusal, if one ever happens, prints the true set.
|
|
479
|
+
*
|
|
480
|
+
* Emits no announcements: nothing has been decided yet.
|
|
481
|
+
*/
|
|
482
|
+
pendingToolPolicyView(getRegistered) {
|
|
483
|
+
const pending = this.registry.pendingFrameNames
|
|
484
|
+
.map((name) => this.registry.get(name))
|
|
485
|
+
.filter((r) => !!r && !r.disabled && !r.invalid);
|
|
486
|
+
if (pending.length === 0)
|
|
487
|
+
return undefined;
|
|
488
|
+
const registered = getRegistered();
|
|
489
|
+
return toView(computeToolPolicy({
|
|
490
|
+
frame: pending,
|
|
491
|
+
registered,
|
|
492
|
+
floor: SKILL_TOOL_FLOOR.filter((n) => registered.includes(n)),
|
|
493
|
+
mode: this.effectiveToolPolicy().mode,
|
|
494
|
+
}));
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* One notice per skill per session when a frame member stops constraining
|
|
498
|
+
* because it was uninstalled, disabled, or turned invalid (D-G20 / AC-G21).
|
|
499
|
+
*
|
|
500
|
+
* Without this, `/skills disable`, a `/skills reload` after an uninstall, and a
|
|
501
|
+
* flip to `integrity: strict` each end with the ceiling gone and nothing said
|
|
502
|
+
* — a silent lift, which is the same failure class as the silent no-op this
|
|
503
|
+
* whole iteration set out to remove.
|
|
504
|
+
*/
|
|
505
|
+
reportCeilingLifted() {
|
|
506
|
+
for (const name of this.registry.frameNames) {
|
|
507
|
+
const record = this.registry.get(name);
|
|
508
|
+
if (record && !record.disabled && !record.invalid)
|
|
509
|
+
continue;
|
|
510
|
+
// A skill that never declared anything was never contributing, so its
|
|
511
|
+
// disappearance lifts nothing and is not worth a line.
|
|
512
|
+
if (record && record.frontmatter.allowedTools.length === 0)
|
|
513
|
+
continue;
|
|
514
|
+
if (!this.registry.noteFailOpenOnce(`lifted:${name}`))
|
|
515
|
+
continue;
|
|
516
|
+
this.deps.notify?.('warn', `Tool ceiling lifted: skill "${name}" is no longer available (uninstalled/disabled).`);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
/** One notice per skill per session when D-G4 waives it (§5.7 / RG3). */
|
|
520
|
+
reportFailOpen(decision) {
|
|
521
|
+
for (const entry of decision.ignored) {
|
|
522
|
+
if (!this.registry.noteFailOpenOnce(`failopen:${entry.name}`))
|
|
523
|
+
continue;
|
|
524
|
+
this.deps.notify?.('warn', `Skill "${entry.name}" declares tools this host does not recognise ` +
|
|
525
|
+
`(${entry.unresolved.join(', ')}), so it is NOT ENFORCEABLE and imposes no tool ceiling. ` +
|
|
526
|
+
'Run "aragon skills doctor" for details.');
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
/** Print the union-escalation trace (§5.6e / D-G17) and remember the new state. */
|
|
530
|
+
reportCeilingWidened(decision) {
|
|
531
|
+
const previous = this.lastDecisionAllowed;
|
|
532
|
+
if (decision.allowed !== null && previous !== null) {
|
|
533
|
+
const added = [...decision.allowed]
|
|
534
|
+
.filter((tool) => !previous.has(tool))
|
|
535
|
+
.sort((a, b) => a.localeCompare(b, 'en'));
|
|
536
|
+
if (added.length > 0) {
|
|
537
|
+
const fresh = decision.sourceNames.filter((n) => !this.lastDecisionSources.has(n));
|
|
538
|
+
const by = (fresh.length > 0 ? fresh : decision.sourceNames).join('", "');
|
|
539
|
+
this.deps.notify?.('warn', `Tool ceiling widened by skill "${by}": ${added.map((t) => `+${t}`).join(', ')}`);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
this.lastDecisionAllowed = decision.allowed;
|
|
543
|
+
this.lastDecisionSources = new Set(decision.sourceNames);
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* The single place the three announce-once rules live (§5.4c / P1-2).
|
|
547
|
+
*
|
|
548
|
+
* `verdict.message` is MUTATED in the escalate branch rather than being
|
|
549
|
+
* followed by a second message: the model is already reading this text, and a
|
|
550
|
+
* separate message is one more thing for it to skip.
|
|
551
|
+
*/
|
|
552
|
+
reportToolPolicyEvent(e) {
|
|
553
|
+
// A blocked tool with several sources still needs ONE bucket key. Attribution
|
|
554
|
+
// lives in `message`, which lists every source; this is only about how often
|
|
555
|
+
// the same complaint reaches the transcript.
|
|
556
|
+
const primary = e.sourceNames[0] ?? 'skill';
|
|
557
|
+
if (e.verdict.allow) {
|
|
558
|
+
if (!this.registry.noteWarnOnce(primary, e.tool))
|
|
559
|
+
return;
|
|
560
|
+
if (e.verdict.notice)
|
|
561
|
+
this.deps.notify?.('warn', e.verdict.notice);
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
if (e.verdict.notice)
|
|
565
|
+
this.deps.notify?.('error', e.verdict.notice);
|
|
566
|
+
const count = this.registry.countDeny(e.tool);
|
|
567
|
+
if (count === TOOL_POLICY_DENY_ESCALATE_AT) {
|
|
568
|
+
e.verdict.message = `${e.verdict.message ?? ''}\n${renderDenyEscalation(e.tool, count)}`;
|
|
569
|
+
this.deps.notify?.('warn', renderDenyEscalationNotice(primary, e.tool, count));
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
// Frame lifecycle — thin forwards so the controller never reaches into the
|
|
573
|
+
// registry itself (`controller.ts` owns *when*, this class owns *what*).
|
|
574
|
+
beginUserTurn() {
|
|
575
|
+
this.registry.beginUserTurn();
|
|
576
|
+
this.lastDecisionAllowed = null;
|
|
577
|
+
this.lastDecisionSources = new Set();
|
|
578
|
+
}
|
|
579
|
+
absorbPendingFrames() {
|
|
580
|
+
this.registry.absorbPendingFrames();
|
|
581
|
+
}
|
|
582
|
+
queueFrame(name) {
|
|
583
|
+
this.registry.queueFrame(name);
|
|
584
|
+
}
|
|
585
|
+
clearFrames() {
|
|
586
|
+
this.registry.clearFrames();
|
|
587
|
+
this.lastDecisionAllowed = null;
|
|
588
|
+
this.lastDecisionSources = new Set();
|
|
589
|
+
}
|
|
590
|
+
frameNames() {
|
|
591
|
+
return this.registry.frameNames;
|
|
592
|
+
}
|
|
593
|
+
/** Level 2 body budget — shared by the tool path and the slash path (GG3). */
|
|
594
|
+
bodyMaxBytes() {
|
|
595
|
+
return this.config.bodyMaxBytes;
|
|
596
|
+
}
|
|
597
|
+
/** Warn once about `--skill <name>` values that match nothing (§7.5). */
|
|
598
|
+
reportUnknownForcedSkills() {
|
|
599
|
+
for (const name of this.deps.runtime.forcedSkills) {
|
|
600
|
+
if (!this.registry.get(name)) {
|
|
601
|
+
this.deps.notify?.('warn', `--skill "${name}" does not match any installed skill.`);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
// -----------------------------------------------------------------------
|
|
606
|
+
// Mutators
|
|
607
|
+
// -----------------------------------------------------------------------
|
|
608
|
+
setDisabled(name, disabled) {
|
|
609
|
+
this.registry.setDisabled(name, disabled);
|
|
610
|
+
const next = new Set(this.config.disabled);
|
|
611
|
+
if (disabled)
|
|
612
|
+
next.add(name);
|
|
613
|
+
else
|
|
614
|
+
next.delete(name);
|
|
615
|
+
this.updateConfig({ disabled: [...next] });
|
|
616
|
+
this.deps.onChange?.();
|
|
617
|
+
}
|
|
618
|
+
getConfig() {
|
|
619
|
+
return this.config;
|
|
620
|
+
}
|
|
621
|
+
/** Local use counters backing `--sort=recent`; empty when tracking is off. */
|
|
622
|
+
getUsage() {
|
|
623
|
+
return this.config.usageTracking ? this.usage : {};
|
|
624
|
+
}
|
|
625
|
+
/** Working directory an install should resolve `--scope project` against. */
|
|
626
|
+
getCwdForInstall() {
|
|
627
|
+
return this.deps.getCwd();
|
|
628
|
+
}
|
|
629
|
+
getRuntime() {
|
|
630
|
+
return this.deps.runtime;
|
|
631
|
+
}
|
|
632
|
+
getApproval() {
|
|
633
|
+
return this.deps.approval;
|
|
634
|
+
}
|
|
635
|
+
notify(level, text) {
|
|
636
|
+
this.deps.notify?.(level, text);
|
|
637
|
+
}
|
|
638
|
+
/** Keep the in-memory copy and the config file in step (deep-merged, §10.3.2). */
|
|
639
|
+
updateConfig(patch) {
|
|
640
|
+
this.config = { ...this.config, ...patch };
|
|
641
|
+
this.deps.persist?.(patch);
|
|
642
|
+
}
|
|
643
|
+
// -----------------------------------------------------------------------
|
|
644
|
+
// Directory trust (D13 / §9.3)
|
|
645
|
+
// -----------------------------------------------------------------------
|
|
646
|
+
isTrusted(dir) {
|
|
647
|
+
const normalized = normalizeTrustPath(dir);
|
|
648
|
+
if (!normalized)
|
|
649
|
+
return false;
|
|
650
|
+
return this.config.trustedProjectDirs.some((t) => normalizeTrustPath(t) === normalized);
|
|
651
|
+
}
|
|
652
|
+
trustDir(dir) {
|
|
653
|
+
const normalized = normalizeTrustPath(dir);
|
|
654
|
+
if (!normalized)
|
|
655
|
+
return false;
|
|
656
|
+
if (this.config.trustedProjectDirs.includes(normalized))
|
|
657
|
+
return true;
|
|
658
|
+
this.updateConfig({ trustedProjectDirs: [...this.config.trustedProjectDirs, normalized] });
|
|
659
|
+
return true;
|
|
660
|
+
}
|
|
661
|
+
untrustDir(dir) {
|
|
662
|
+
const normalized = normalizeTrustPath(dir) ?? dir;
|
|
663
|
+
const next = this.config.trustedProjectDirs.filter((t) => (normalizeTrustPath(t) ?? t) !== normalized);
|
|
664
|
+
if (next.length === this.config.trustedProjectDirs.length)
|
|
665
|
+
return false;
|
|
666
|
+
this.updateConfig({ trustedProjectDirs: next });
|
|
667
|
+
return true;
|
|
668
|
+
}
|
|
669
|
+
/**
|
|
670
|
+
* Load a project root for THIS SESSION ONLY, without persisting trust.
|
|
671
|
+
* Used when the user answers the confirm dialog with "just this once".
|
|
672
|
+
*/
|
|
673
|
+
trustForSession(dir) {
|
|
674
|
+
this.pendingTrust = this.pendingTrust.filter((d) => d !== dir);
|
|
675
|
+
const root = { dir, scope: 'project', writable: dir.includes('.aragon') };
|
|
676
|
+
const errors = [];
|
|
677
|
+
for (const record of this.scanRoot(root, errors))
|
|
678
|
+
this.registry.add(record);
|
|
679
|
+
for (const name of this.config.disabled)
|
|
680
|
+
this.registry.setDisabled(name, true);
|
|
681
|
+
this.lastErrors = [...this.lastErrors, ...errors];
|
|
682
|
+
this.deps.onChange?.();
|
|
683
|
+
}
|
|
684
|
+
// -----------------------------------------------------------------------
|
|
685
|
+
// Housekeeping
|
|
686
|
+
// -----------------------------------------------------------------------
|
|
687
|
+
/**
|
|
688
|
+
* Drop staging directories left behind by a crashed install (P2-10).
|
|
689
|
+
* Same discipline as the host repo's workspace cleanup: age-based, best
|
|
690
|
+
* effort, and never allowed to interrupt discovery.
|
|
691
|
+
*/
|
|
692
|
+
reclaimStaleStaging() {
|
|
693
|
+
const staging = getStagingDir();
|
|
694
|
+
if (!isDirectory(staging))
|
|
695
|
+
return;
|
|
696
|
+
const cutoff = Date.now() - STAGING_TTL_MS;
|
|
697
|
+
let entries;
|
|
698
|
+
try {
|
|
699
|
+
entries = this.deps.host.listDir(staging);
|
|
700
|
+
}
|
|
701
|
+
catch {
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
for (const entry of entries) {
|
|
705
|
+
if (!entry.isDirectory)
|
|
706
|
+
continue;
|
|
707
|
+
const dir = join(staging, entry.name);
|
|
708
|
+
try {
|
|
709
|
+
if (statSync(dir).mtimeMs >= cutoff)
|
|
710
|
+
continue;
|
|
711
|
+
rmSync(dir, { recursive: true, force: true });
|
|
712
|
+
}
|
|
713
|
+
catch {
|
|
714
|
+
// Best effort: a locked directory is retried on the next launch.
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
function message(err) {
|
|
720
|
+
return err instanceof Error ? err.message : String(err);
|
|
721
|
+
}
|
|
722
|
+
/** Decision → the flat, sorted shape the renderers take. `null` means "say nothing". */
|
|
723
|
+
function toView(decision) {
|
|
724
|
+
if (decision.allowed === null)
|
|
725
|
+
return undefined;
|
|
726
|
+
return {
|
|
727
|
+
mode: decision.mode,
|
|
728
|
+
allowed: [...decision.allowed].sort((a, b) => a.localeCompare(b, 'en')),
|
|
729
|
+
};
|
|
730
|
+
}
|