@stage-labs/metro 0.1.0-beta.279 → 0.1.0-beta.280
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/package.json +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/node_modules/@metro-labs/core/package.json +1 -0
- package/runtime/node_modules/@metro-labs/core/src/skill-source.ts +96 -0
- package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/api.ts +30 -5
- package/runtime/node_modules/@metro-labs/daemon/src/claude/github-api.ts +12 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/github-fetch.ts +160 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/github-skills.ts +257 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/github-validate.ts +53 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/skills.ts +2 -0
- package/runtime/runtime.json +1 -1
- package/runtime/sdk-runner/node_modules/@metro-labs/core/package.json +1 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/skill-source.ts +96 -0
- package/runtime/sdk-runner/runner.json +1 -1
- package/runtime/sdk-runner/src/app.ts +4 -1
- package/runtime/sdk-runner/src/runner.ts +26 -3
- package/runtime/sdk-runner/src/skill-work.ts +76 -0
- package/runtime/sdk-runner/src/skills-files.ts +169 -0
- package/runtime/sdk-runner/src/skills.ts +150 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { isRecord } from '@metro-labs/core/is-record';
|
|
3
|
+
import { SKILL_NAME, skillGeneration, type SkillGeneration, type SkillSource } from '@metro-labs/core/skill-source';
|
|
4
|
+
import { ApiError } from '@metro-labs/http/api-error';
|
|
5
|
+
import type { GitHubSnapshot } from './github-fetch.js';
|
|
6
|
+
|
|
7
|
+
const FIELDS = new Set(['name', 'description', 'argument-hint', 'disable-model-invocation', 'user-invocable']);
|
|
8
|
+
export const skillDigest = (bytes: string | Uint8Array): string => createHash('sha256').update(bytes).digest('hex');
|
|
9
|
+
|
|
10
|
+
function validRef(value: unknown): value is string {
|
|
11
|
+
return typeof value === 'string' && value.length <= 200 && /^[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(value) && !value.includes('..') && !value.includes('//');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function validFolder(value: unknown): value is string {
|
|
15
|
+
if (typeof value !== 'string' || value.length > 240) return false;
|
|
16
|
+
if (value === '') return true;
|
|
17
|
+
const parts = value.split('/');
|
|
18
|
+
return parts.length <= 6 && parts.every((part) => /^[A-Za-z0-9._-]{1,80}$/.test(part) && part !== '.' && part !== '..');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function githubSource(raw: unknown): SkillSource & { token: string } {
|
|
22
|
+
if (!isRecord(raw) || typeof raw.repository !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9-]{0,38}\/[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/.test(raw.repository)) throw new ApiError('Use a GitHub repository in owner/repository form.', 400);
|
|
23
|
+
if (!validRef(raw.ref)) throw new ApiError('Use a branch, tag or full commit SHA.', 400);
|
|
24
|
+
if (!validFolder(raw.folder)) throw new ApiError('Use a relative skills folder, or leave it empty for the repository root.', 400);
|
|
25
|
+
if (typeof raw.token !== 'string' || !/^github_pat_[A-Za-z0-9_]{20,240}$/.test(raw.token)) throw new ApiError('Use a fine-grained GitHub token with Contents read access to this repository only.', 400);
|
|
26
|
+
return { repository: raw.repository, ref: raw.ref, folder: raw.folder, token: raw.token };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function passiveMetadata(meta: Record<string, unknown>): void {
|
|
30
|
+
if (Object.keys(meta).some((key) => !FIELDS.has(key))) throw new Error('Unsupported skill metadata. Only name, description, argument-hint, disable-model-invocation and user-invocable are allowed. Hooks, tool grants, model changes and agents are not imported.');
|
|
31
|
+
if (['disable-model-invocation', 'user-invocable'].some((key) => key in meta && typeof meta[key] !== 'boolean') || ('argument-hint' in meta && typeof meta['argument-hint'] !== 'string')) throw new Error('Invalid skill metadata types.');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function skillMetadata(name: string, bytes: Buffer): { name: string; description: string } {
|
|
35
|
+
const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
36
|
+
const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(text);
|
|
37
|
+
if (match === null || /!`|\0/.test(text)) throw new Error('A skill needs YAML frontmatter. Shell substitutions are not supported.');
|
|
38
|
+
const meta: unknown = Bun.YAML.parse(match[1] ?? '');
|
|
39
|
+
if (!isRecord(meta) || meta.name !== name || !SKILL_NAME.test(name) || typeof meta.description !== 'string' || meta.description.trim() === '' || meta.description.length > 1024) throw new Error('A skill needs a name matching its folder and a description of up to 1024 characters.');
|
|
40
|
+
passiveMetadata(meta);
|
|
41
|
+
return { name, description: meta.description };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function validatedGeneration(source: SkillSource | null, snapshot: GitHubSnapshot | null): SkillGeneration {
|
|
45
|
+
const files = snapshot?.files ?? new Map<string, Buffer>();
|
|
46
|
+
const skills = [...files].filter(([path]) => path.endsWith('/SKILL.md')).map(([path, bytes]) => skillMetadata(path.split('/')[0] ?? '', bytes));
|
|
47
|
+
const manifest = {
|
|
48
|
+
source: source === null || snapshot === null ? null : { repository: source.repository, ref: source.ref, folder: source.folder, repositoryId: snapshot.repositoryId, commit: snapshot.commit },
|
|
49
|
+
skills,
|
|
50
|
+
files: [...files].map(([path, bytes]) => ({ path, size: bytes.byteLength, sha256: skillDigest(bytes), ...(snapshot?.executables?.includes(path) === true ? { executable: true } : {}) })),
|
|
51
|
+
};
|
|
52
|
+
return skillGeneration({ ...manifest, id: skillDigest(randomUUID()), createdAt: new Date().toISOString() });
|
|
53
|
+
}
|
package/runtime/runtime.json
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"./automation-types": "./src/automation-types.ts",
|
|
14
14
|
"./call": "./src/call.ts",
|
|
15
15
|
"./secure-fs": "./src/secure-fs.ts",
|
|
16
|
+
"./skill-source": "./src/skill-source.ts",
|
|
16
17
|
"./version": "./src/version.ts",
|
|
17
18
|
"./train-error": "./src/train-error.ts",
|
|
18
19
|
"./trains/protocol": "./src/protocol.ts",
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { isRecord } from './is-record.js';
|
|
3
|
+
|
|
4
|
+
export const SKILL_NAME = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
5
|
+
export const SKILL_HASH = /^[a-f0-9]{64}$/;
|
|
6
|
+
export const SKILL_COMMIT = /^[a-f0-9]{40}$/;
|
|
7
|
+
export const SKILL_FILE_MAX = 256 * 1024;
|
|
8
|
+
export const SKILL_TOTAL_MAX = 8 * 1024 * 1024;
|
|
9
|
+
export const SKILL_FILES_MAX = 256;
|
|
10
|
+
export const SKILL_MANIFEST_MAX = 128 * 1024;
|
|
11
|
+
|
|
12
|
+
export interface SkillSource {
|
|
13
|
+
repository: string;
|
|
14
|
+
ref: string;
|
|
15
|
+
folder: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SkillFile {
|
|
19
|
+
path: string;
|
|
20
|
+
size: number;
|
|
21
|
+
sha256: string;
|
|
22
|
+
executable?: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface SkillGeneration {
|
|
26
|
+
id: string;
|
|
27
|
+
source: (SkillSource & { repositoryId: number; commit: string }) | null;
|
|
28
|
+
createdAt: string;
|
|
29
|
+
skills: { name: string; description: string }[];
|
|
30
|
+
files: SkillFile[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface SkillActivation {
|
|
34
|
+
generation: string | null;
|
|
35
|
+
loading: string | null;
|
|
36
|
+
rollback?: string | null;
|
|
37
|
+
appliedAt: string | null;
|
|
38
|
+
problem: string | null;
|
|
39
|
+
shadowed: string[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const skillSourceRoot = (claudeDir: string): string => join(claudeDir, 'metro-github');
|
|
43
|
+
export const skillRelease = (root: string, id: string): string => {
|
|
44
|
+
if (!SKILL_HASH.test(id)) throw new Error('Invalid skills generation.');
|
|
45
|
+
return join(root, 'releases', id);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export function skillPath(path: string): boolean {
|
|
49
|
+
const parts = path.split('/');
|
|
50
|
+
return path.length <= 240 && parts.length >= 2 && parts.length <= 8 && SKILL_NAME.test(parts[0] ?? '') &&
|
|
51
|
+
parts.every((part) => /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/.test(part)) &&
|
|
52
|
+
!parts.slice(1, -1).includes('SKILL.md') && !(parts.length > 2 && parts.at(-1) === 'SKILL.md');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function manifestSkill(value: unknown): SkillGeneration['skills'][number] {
|
|
56
|
+
if (!isRecord(value) || typeof value.name !== 'string' || !SKILL_NAME.test(value.name) || typeof value.description !== 'string' || value.description.length > 1024) throw new Error('Invalid skill name or description.');
|
|
57
|
+
return { name: value.name, description: value.description };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function fileSize(value: unknown): number {
|
|
61
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value < 0 || value > SKILL_FILE_MAX) throw new Error('Invalid skills file size.');
|
|
62
|
+
return value;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function manifestFile(value: unknown): SkillFile {
|
|
66
|
+
if (!isRecord(value)) throw new Error('Invalid skills file.');
|
|
67
|
+
if (typeof value.path !== 'string' || !skillPath(value.path) || typeof value.sha256 !== 'string' || !SKILL_HASH.test(value.sha256)) throw new Error('Invalid skills file path or hash.');
|
|
68
|
+
return { path: value.path, size: fileSize(value.size), sha256: value.sha256, ...(value.executable === true ? { executable: true } : {}) };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function manifestSource(s: unknown): SkillGeneration['source'] {
|
|
72
|
+
if (s === null) return null;
|
|
73
|
+
if (!isRecord(s) || typeof s.repository !== 'string' || typeof s.ref !== 'string' || typeof s.folder !== 'string' || typeof s.repositoryId !== 'number' || typeof s.commit !== 'string' || !SKILL_COMMIT.test(s.commit)) throw new Error('Invalid skills provenance.');
|
|
74
|
+
return { repository: s.repository, ref: s.ref, folder: s.folder, repositoryId: s.repositoryId, commit: s.commit };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function checkFiles(skills: SkillGeneration['skills'], files: SkillFile[]): void {
|
|
78
|
+
if (files.reduce((sum, file) => sum + file.size, 0) > SKILL_TOTAL_MAX || new Set(files.map((f) => f.path.toLowerCase())).size !== files.length || new Set(skills.map((s) => s.name)).size !== skills.length) throw new Error('Duplicate or oversized skills files.');
|
|
79
|
+
if (files.some((f) => !skills.some((s) => f.path.startsWith(`${s.name}/`))) || skills.some((s) => !files.some((f) => f.path === `${s.name}/SKILL.md`))) throw new Error('Every skill needs its own SKILL.md.');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function boundedGeneration(generation: SkillGeneration): SkillGeneration {
|
|
83
|
+
if (Buffer.byteLength(JSON.stringify(generation)) > SKILL_MANIFEST_MAX) throw new Error('Skills manifest is too large.');
|
|
84
|
+
return generation;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function skillGeneration(raw: unknown): SkillGeneration {
|
|
88
|
+
if (!isRecord(raw) || typeof raw.id !== 'string' || !SKILL_HASH.test(raw.id) || typeof raw.createdAt !== 'string' || !Array.isArray(raw.skills) || !Array.isArray(raw.files)) throw new Error('Invalid skills manifest.');
|
|
89
|
+
if (raw.skills.length > 64 || raw.files.length > SKILL_FILES_MAX) throw new Error('Skills manifest is too large.');
|
|
90
|
+
const skills = raw.skills.map(manifestSkill);
|
|
91
|
+
const files = raw.files.map(manifestFile);
|
|
92
|
+
const source = manifestSource(raw.source);
|
|
93
|
+
checkFiles(skills, files);
|
|
94
|
+
if (source === null) checkFiles([], files);
|
|
95
|
+
return boundedGeneration({ id: raw.id, source, createdAt: raw.createdAt, skills, files });
|
|
96
|
+
}
|
|
@@ -2,6 +2,8 @@ import { dirname, join } from 'node:path';
|
|
|
2
2
|
import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk';
|
|
3
3
|
import { AutomationStore } from '@metro-labs/core/automation-store';
|
|
4
4
|
import { errMsg, log } from '@metro-labs/core/log';
|
|
5
|
+
import { skillSourceRoot } from '@metro-labs/core/skill-source';
|
|
6
|
+
import { SkillsRefresh } from './skills.js';
|
|
5
7
|
import { failureSummary, type Activity } from './activity.js';
|
|
6
8
|
import { approvalsThrough } from './approvals.js';
|
|
7
9
|
import type { RunnerConfig } from './config.js';
|
|
@@ -30,7 +32,8 @@ async function connected(cfg: RunnerConfig, hooks: AppHooks, release: () => Prom
|
|
|
30
32
|
const store = new SessionStore(cfg.statePath, cfg.claudeDir, cfg.cwd);
|
|
31
33
|
let tools: MetroTools | null = null;
|
|
32
34
|
const automationStore = new AutomationStore(join(dirname(cfg.statePath), 'automation'));
|
|
33
|
-
const
|
|
35
|
+
const skills = new SkillsRefresh(skillSourceRoot(cfg.claudeDir), cfg.claudeDir);
|
|
36
|
+
const runner = new Runner({ store, automationStore, skills, readOnly: (tool) => tools?.readOnly(tool) ?? false, ...hooks });
|
|
34
37
|
const link = await MetroLink.open(cfg.mcpUrl, cfg.key, {
|
|
35
38
|
channel: (event) => { runner.chat(event); },
|
|
36
39
|
call: (notice) => { runner.calls.notice(notice); },
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { query, type CanUseTool, type Options, type Query, type SDKMessage, type SDKUserMessage } from '@anthropic-ai/claude-agent-sdk';
|
|
2
2
|
import { errMsg, log } from '@metro-labs/core/log';
|
|
3
|
+
import { skillSourceRoot } from '@metro-labs/core/skill-source';
|
|
4
|
+
import { type SkillsRefresh } from './skills.js';
|
|
5
|
+
import { SkillWork } from './skill-work.js';
|
|
3
6
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
4
7
|
import type { AutomationStore } from '@metro-labs/core/automation-store';
|
|
5
8
|
import type { Activity } from './activity.js';
|
|
@@ -41,6 +44,7 @@ export function runnerOptions(cfg: RunnerConfig, tools: MetroTools, canUseTool:
|
|
|
41
44
|
env: runnerEnv(env),
|
|
42
45
|
...(cfg.claude === null ? {} : { pathToClaudeCodeExecutable: cfg.claude }),
|
|
43
46
|
settingSources: ['user', 'project', 'local'],
|
|
47
|
+
additionalDirectories: [skillSourceRoot(cfg.claudeDir)],
|
|
44
48
|
systemPrompt: { type: 'preset', preset: 'claude_code', append: cfg.prompt === null ? FRONT_RULES : `${cfg.prompt}\n\n${FRONT_RULES}` },
|
|
45
49
|
mcpServers: { [METRO_SERVER]: tools.config },
|
|
46
50
|
permissionMode: cfg.permissionMode,
|
|
@@ -73,6 +77,7 @@ export interface RunnerParts {
|
|
|
73
77
|
open?: OpenSession;
|
|
74
78
|
activity?: Activity;
|
|
75
79
|
automationStore?: AutomationStore;
|
|
80
|
+
skills?: SkillsRefresh;
|
|
76
81
|
}
|
|
77
82
|
|
|
78
83
|
function streamOutput(event: Record<string, unknown>): boolean {
|
|
@@ -94,6 +99,7 @@ export class Runner {
|
|
|
94
99
|
private readonly recovery: TaskRecovery;
|
|
95
100
|
private readonly watch: SessionWatch;
|
|
96
101
|
private readonly tasks = new ActivityTasks(() => undefined);
|
|
102
|
+
private readonly skillWork = new SkillWork();
|
|
97
103
|
private compactTimer: ReturnType<typeof setTimeout> | null = null;
|
|
98
104
|
private session: Query | null = null;
|
|
99
105
|
private sessionId: string | null = null;
|
|
@@ -109,7 +115,7 @@ export class Runner {
|
|
|
109
115
|
constructor(private readonly parts: RunnerParts) {
|
|
110
116
|
this.watch = new SessionWatch(parts.readOnly);
|
|
111
117
|
this.inbox = new Inbox((unanswered) => { this.keep(unanswered); }, {
|
|
112
|
-
ready: () => this.watch.admitting,
|
|
118
|
+
ready: () => { parts.skills?.check(); return this.watch.admitting && parts.skills?.busy !== true; },
|
|
113
119
|
dispatch: (message) => { this.automation?.dispatched(message.uuid); this.watch.dispatched(message.uuid); },
|
|
114
120
|
input: (input) => { this.cancelCompact(); parts.activity?.input(input); },
|
|
115
121
|
queue: (count, oldest) => { parts.activity?.pending(count, oldest); },
|
|
@@ -146,6 +152,11 @@ export class Runner {
|
|
|
146
152
|
this.session = (this.parts.open ?? query)({ prompt: this.inbox, options });
|
|
147
153
|
this.model = options.model ?? null;
|
|
148
154
|
this.queueModel(this.model);
|
|
155
|
+
this.parts.skills?.start({
|
|
156
|
+
safe: () => !this.ended && this.watch.safe && this.watch.admitting && this.compactAsked === null && !this.calls.live && this.skillWork.safe,
|
|
157
|
+
reload: () => this.session?.reloadSkills() ?? Promise.reject(new Error('The SDK session is closed.')),
|
|
158
|
+
wake: () => { this.inbox.notify(); },
|
|
159
|
+
});
|
|
149
160
|
this.recovery.start();
|
|
150
161
|
this.automation?.start();
|
|
151
162
|
return this.session;
|
|
@@ -167,7 +178,7 @@ export class Runner {
|
|
|
167
178
|
this.recovery.observe(m);
|
|
168
179
|
this.automation?.observe(m);
|
|
169
180
|
this.trackInput(m);
|
|
170
|
-
this.
|
|
181
|
+
this.observeWork(m);
|
|
171
182
|
this.note(m);
|
|
172
183
|
this.parts.activity?.observe(message);
|
|
173
184
|
this.inbox.notify();
|
|
@@ -176,6 +187,7 @@ export class Runner {
|
|
|
176
187
|
}
|
|
177
188
|
} finally {
|
|
178
189
|
this.cancelCompact();
|
|
190
|
+
this.parts.skills?.close();
|
|
179
191
|
this.automation?.close(false);
|
|
180
192
|
this.recovery.close(false);
|
|
181
193
|
this.ended = true;
|
|
@@ -200,6 +212,7 @@ export class Runner {
|
|
|
200
212
|
|
|
201
213
|
close(cancelActive = false): void {
|
|
202
214
|
this.cancelCompact();
|
|
215
|
+
this.parts.skills?.close();
|
|
203
216
|
this.calls.close();
|
|
204
217
|
try {
|
|
205
218
|
this.recovery.close(cancelActive);
|
|
@@ -281,8 +294,18 @@ export class Runner {
|
|
|
281
294
|
}
|
|
282
295
|
}
|
|
283
296
|
|
|
297
|
+
private observeWork(message: Record<string, unknown>): void {
|
|
298
|
+
this.watch.observe(message);
|
|
299
|
+
this.skillWork.observe(message);
|
|
300
|
+
this.parts.skills?.check();
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
private get maintenanceBusy(): boolean {
|
|
304
|
+
return this.ended || this.parts.skills?.busy === true || this.compactAsked !== null || !this.watch.safe || !this.watch.admitting;
|
|
305
|
+
}
|
|
306
|
+
|
|
284
307
|
private compactReady(resuming = false): boolean {
|
|
285
|
-
const busy = this.
|
|
308
|
+
const busy = this.maintenanceBusy || (!resuming && (this.calls.live || this.inbox.pending > 0 || this.tasks.running > 0));
|
|
286
309
|
return compactDue({ context: this.watch.context, floor: this.compactFloor, limit: this.parts.compactAt ?? COMPACT_AT, busy });
|
|
287
310
|
}
|
|
288
311
|
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { isRecord } from '@metro-labs/core/is-record';
|
|
2
|
+
|
|
3
|
+
const TERMINAL = new Set(['completed', 'failed', 'killed', 'stopped']);
|
|
4
|
+
interface Task { tool: string | null; background: boolean }
|
|
5
|
+
|
|
6
|
+
export class SkillWork {
|
|
7
|
+
private readonly tasks = new Map<string, Task>();
|
|
8
|
+
private readonly tools = new Map<string, string | null>();
|
|
9
|
+
private overflow = false;
|
|
10
|
+
get safe(): boolean { return !this.overflow && this.tasks.size === 0 && this.tools.size === 0; }
|
|
11
|
+
|
|
12
|
+
private blocks(message: Record<string, unknown>): void {
|
|
13
|
+
if (!isRecord(message.message) || !Array.isArray(message.message.content)) return;
|
|
14
|
+
const parent = typeof message.parent_tool_use_id === 'string' ? message.parent_tool_use_id : null;
|
|
15
|
+
for (const block of message.message.content.filter(isRecord)) {
|
|
16
|
+
if (block.type === 'tool_use' && typeof block.id === 'string') this.tools.set(block.id, parent);
|
|
17
|
+
if (block.type === 'tool_result' && typeof block.tool_use_id === 'string') this.tools.delete(block.tool_use_id);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
private retire(id: string, tool = this.tasks.get(id)?.tool): void {
|
|
22
|
+
this.tasks.delete(id);
|
|
23
|
+
if (tool == null) return;
|
|
24
|
+
const retired = new Set([tool]);
|
|
25
|
+
for (let changed = true; changed;) {
|
|
26
|
+
changed = false;
|
|
27
|
+
for (const [child, parent] of this.tools) {
|
|
28
|
+
if (parent === null || !retired.has(parent) || retired.has(child)) continue;
|
|
29
|
+
retired.add(child);
|
|
30
|
+
changed = true;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
for (const child of retired) this.tools.delete(child);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
private background(id: string, value: unknown): boolean {
|
|
37
|
+
return typeof value === 'boolean' ? value : this.tasks.get(id)?.background ?? false;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
private task(message: Record<string, unknown>): void {
|
|
41
|
+
if (typeof message.task_id !== 'string') return;
|
|
42
|
+
const id = message.task_id;
|
|
43
|
+
const tool = typeof message.tool_use_id === 'string' ? message.tool_use_id : this.tasks.get(id)?.tool ?? null;
|
|
44
|
+
if (message.subtype === 'task_started') this.tasks.set(id, { tool, background: this.background(id, message.is_backgrounded) });
|
|
45
|
+
if (message.subtype === 'task_notification') this.retire(id, tool);
|
|
46
|
+
if (message.subtype === 'task_updated' && isRecord(message.patch)) this.patch(id, message.patch);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private patch(id: string, patch: Record<string, unknown>): void {
|
|
50
|
+
if (TERMINAL.has(String(patch.status))) { this.retire(id); return; }
|
|
51
|
+
if (!this.tasks.has(id) && !['running', 'paused', 'pending'].includes(String(patch.status))) return;
|
|
52
|
+
const task = this.tasks.get(id) ?? { tool: null, background: false };
|
|
53
|
+
if (typeof patch.is_backgrounded === 'boolean') task.background = patch.is_backgrounded;
|
|
54
|
+
this.tasks.set(id, task);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private snapshot(entries: unknown[]): void {
|
|
58
|
+
const ids = new Set(entries.filter(isRecord).flatMap((task) => typeof task.task_id === 'string' ? [task.task_id] : []));
|
|
59
|
+
for (const [id, task] of this.tasks) if (task.background && !ids.has(id)) this.retire(id);
|
|
60
|
+
for (const id of ids) this.tasks.set(id, { tool: this.tasks.get(id)?.tool ?? null, background: true });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private frontDone(message: Record<string, unknown>): void {
|
|
64
|
+
if (message.parent_tool_use_id != null) return;
|
|
65
|
+
for (const [id, parent] of this.tools) if (parent === null) this.tools.delete(id);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
observe(message: Record<string, unknown>): void {
|
|
69
|
+
if (this.overflow) return;
|
|
70
|
+
this.blocks(message);
|
|
71
|
+
if (message.type === 'result') this.frontDone(message);
|
|
72
|
+
if (message.type === 'system') this.task(message);
|
|
73
|
+
if (message.subtype === 'background_tasks_changed' && Array.isArray(message.tasks)) this.snapshot(message.tasks);
|
|
74
|
+
this.overflow = this.tools.size > 4096 || this.tasks.size > 4096;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { closeSync, constants, fstatSync, openSync, readSync, lstatSync, statSync, mkdirSync, readFileSync, readdirSync, readlinkSync, renameSync, rmSync, symlinkSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { isRecord } from '@metro-labs/core/is-record';
|
|
5
|
+
import { SKILL_FILE_MAX, SKILL_HASH, SKILL_MANIFEST_MAX, skillGeneration, skillRelease, type SkillGeneration } from '@metro-labs/core/skill-source';
|
|
6
|
+
|
|
7
|
+
const digest = (bytes: string | Uint8Array): string => createHash('sha256').update(bytes).digest('hex');
|
|
8
|
+
const LINK = /^\.\.\/releases\/[a-f0-9]{64}\/visible-[a-f0-9]{64}$/;
|
|
9
|
+
|
|
10
|
+
function directory(path: string): void {
|
|
11
|
+
mkdirSync(path, { recursive: true, mode: 0o700 });
|
|
12
|
+
if (!lstatSync(path).isDirectory()) throw new Error('The managed skills directory is not a regular directory.');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function prepareSkillsRoot(root: string): void {
|
|
16
|
+
directory(root);
|
|
17
|
+
directory(join(root, 'releases'));
|
|
18
|
+
directory(join(root, '.claude'));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function pendingSkills(root: string): SkillGeneration | null {
|
|
22
|
+
const path = join(root, 'pending.json');
|
|
23
|
+
let stat;
|
|
24
|
+
try { stat = lstatSync(path); } catch (err) { if (isRecord(err) && err.code === 'ENOENT') return null; throw err; }
|
|
25
|
+
if (!stat.isFile() || stat.size > SKILL_MANIFEST_MAX) throw new Error('Invalid pending skills manifest.');
|
|
26
|
+
return skillGeneration(JSON.parse(readFileSync(path, 'utf8')) as unknown);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function localText(file: string): string {
|
|
30
|
+
const fd = openSync(file, constants.O_RDONLY | constants.O_NONBLOCK);
|
|
31
|
+
try {
|
|
32
|
+
const stat = fstatSync(fd);
|
|
33
|
+
if (!stat.isFile() || stat.size > SKILL_FILE_MAX) throw new Error('A local skill cannot be checked safely.');
|
|
34
|
+
const bytes = Buffer.alloc(SKILL_FILE_MAX + 1);
|
|
35
|
+
let size = 0;
|
|
36
|
+
for (;;) {
|
|
37
|
+
const read = readSync(fd, bytes, size, bytes.length - size, size);
|
|
38
|
+
if (read === 0) break;
|
|
39
|
+
size += read;
|
|
40
|
+
if (size === bytes.length) break;
|
|
41
|
+
}
|
|
42
|
+
if (size > SKILL_FILE_MAX) throw new Error('A local skill is too large.');
|
|
43
|
+
return bytes.subarray(0, size).toString('utf8');
|
|
44
|
+
} finally { closeSync(fd); }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type LocalAliases = Map<string, { signature: string; name: string | null }>;
|
|
48
|
+
|
|
49
|
+
function localAlias(file: string, cache: LocalAliases): string | null {
|
|
50
|
+
try {
|
|
51
|
+
const stat = statSync(file);
|
|
52
|
+
const signature = `${stat.ino}:${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}`;
|
|
53
|
+
const cached = cache.get(file);
|
|
54
|
+
if (cached?.signature === signature) return cached.name;
|
|
55
|
+
const header = /^---\r?\n([\s\S]*?)\r?\n---/.exec(localText(file))?.[1];
|
|
56
|
+
const meta: unknown = header === undefined ? null : Bun.YAML.parse(header);
|
|
57
|
+
const name = isRecord(meta) && typeof meta.name === 'string' ? meta.name : null;
|
|
58
|
+
cache.set(file, { signature, name });
|
|
59
|
+
return name;
|
|
60
|
+
} catch (err) { return isRecord(err) && ['ENOENT', 'ENOTDIR'].includes(String(err.code)) ? null : '*'; }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function localSkillNames(claude: string, cache: LocalAliases = new Map()): string[] {
|
|
64
|
+
const names = new Set<string>();
|
|
65
|
+
let folders: string[];
|
|
66
|
+
try { folders = readdirSync(join(claude, 'skills')); } catch (err) { if (isRecord(err) && err.code === 'ENOENT') return []; throw err; }
|
|
67
|
+
if (folders.length > 2000) return ['*'];
|
|
68
|
+
const paths = new Set<string>();
|
|
69
|
+
for (const name of folders) {
|
|
70
|
+
names.add(name);
|
|
71
|
+
const path = join(claude, 'skills', name, 'SKILL.md');
|
|
72
|
+
paths.add(path);
|
|
73
|
+
const alias = localAlias(path, cache);
|
|
74
|
+
if (alias !== null) names.add(alias);
|
|
75
|
+
}
|
|
76
|
+
for (const path of cache.keys()) if (!paths.has(path)) cache.delete(path);
|
|
77
|
+
return [...names].sort();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function projectionKey(generation: SkillGeneration, locals: string[]): string {
|
|
81
|
+
return `${generation.id}:${digest(JSON.stringify(locals))}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function onlyManifestFiles(root: string, generation: SkillGeneration): void {
|
|
85
|
+
const files = new Set(generation.files.map((file) => file.path));
|
|
86
|
+
const directories = new Set(generation.files.flatMap((file) => file.path.split('/').slice(0, -1).map((_part, index, parts) => parts.slice(0, index + 1).join('/'))));
|
|
87
|
+
const visit = (path: string): void => {
|
|
88
|
+
for (const entry of readdirSync(join(root, path), { withFileTypes: true })) {
|
|
89
|
+
const relative = path === '' ? entry.name : `${path}/${entry.name}`;
|
|
90
|
+
if (entry.isDirectory() && directories.has(relative)) visit(relative);
|
|
91
|
+
else if (!entry.isFile() || !files.has(relative)) throw new Error('Unexpected file in staged skills.');
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
visit('');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function verifiedFiles(release: string, generation: SkillGeneration): void {
|
|
98
|
+
directory(release);
|
|
99
|
+
directory(join(release, 'skills'));
|
|
100
|
+
onlyManifestFiles(join(release, 'skills'), generation);
|
|
101
|
+
for (const file of generation.files) {
|
|
102
|
+
let parent = join(release, 'skills');
|
|
103
|
+
for (const part of file.path.split('/').slice(0, -1)) {
|
|
104
|
+
parent = join(parent, part);
|
|
105
|
+
if (!lstatSync(parent).isDirectory()) throw new Error('A managed skill contains a linked directory.');
|
|
106
|
+
}
|
|
107
|
+
const path = join(release, 'skills', file.path);
|
|
108
|
+
const stat = lstatSync(path);
|
|
109
|
+
if (!stat.isFile() || stat.size !== file.size || digest(readFileSync(path)) !== file.sha256) throw new Error('A staged skill changed before activation.');
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function projectSkills(root: string, generation: SkillGeneration, locals: string[]): { target: string; shadowed: string[] } {
|
|
114
|
+
prepareSkillsRoot(root);
|
|
115
|
+
const release = skillRelease(root, generation.id);
|
|
116
|
+
verifiedFiles(release, generation);
|
|
117
|
+
const shadowed = generation.skills.filter((skill) => locals.includes('*') || locals.includes(skill.name)).map((skill) => skill.name);
|
|
118
|
+
const name = `visible-${digest(JSON.stringify(locals))}`;
|
|
119
|
+
const visible = join(release, name);
|
|
120
|
+
directory(visible);
|
|
121
|
+
const expected = generation.skills.filter((skill) => !shadowed.includes(skill.name)).map((skill) => skill.name);
|
|
122
|
+
if (readdirSync(visible).some((entry) => !expected.includes(entry))) throw new Error('Unexpected file in skills projection.');
|
|
123
|
+
for (const skill of generation.skills) {
|
|
124
|
+
if (shadowed.includes(skill.name)) continue;
|
|
125
|
+
const path = join(visible, skill.name);
|
|
126
|
+
try { symlinkSync(`../skills/${skill.name}`, path, 'dir'); }
|
|
127
|
+
catch (err) { if (!isRecord(err) || err.code !== 'EEXIST' || readlinkSync(path) !== `../skills/${skill.name}`) throw err; }
|
|
128
|
+
}
|
|
129
|
+
return { target: `../releases/${generation.id}/${name}`, shadowed };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function currentSkills(root: string): string | null {
|
|
133
|
+
try {
|
|
134
|
+
const target = readlinkSync(join(root, '.claude', 'skills'));
|
|
135
|
+
if (!LINK.test(target)) throw new Error('The managed skills link has an unexpected target.');
|
|
136
|
+
return target;
|
|
137
|
+
} catch (err) { if (isRecord(err) && err.code === 'ENOENT') return null; throw err; }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function switchSkills(root: string, target: string | null): void {
|
|
141
|
+
prepareSkillsRoot(root);
|
|
142
|
+
const link = join(root, '.claude', 'skills');
|
|
143
|
+
currentSkills(root);
|
|
144
|
+
if (target === null) { rmSync(link, { force: true }); return; }
|
|
145
|
+
if (!LINK.test(target)) throw new Error('Invalid managed skills target.');
|
|
146
|
+
const next = `${link}-${randomUUID()}`;
|
|
147
|
+
try { symlinkSync(target, next, 'dir'); renameSync(next, link); }
|
|
148
|
+
finally { rmSync(next, { force: true }); }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function oldRelease(path: string): boolean {
|
|
152
|
+
let stat;
|
|
153
|
+
try { stat = lstatSync(join(path, 'manifest.json')); }
|
|
154
|
+
catch (err) { if (!isRecord(err) || err.code !== 'ENOENT') throw err; stat = lstatSync(path); }
|
|
155
|
+
return Date.now() - stat.mtimeMs > 5 * 60_000;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function pruneSkills(root: string, keep: Set<string>): void {
|
|
159
|
+
const pending = pendingSkills(root);
|
|
160
|
+
if (pending !== null) keep.add(pending.id);
|
|
161
|
+
for (const id of readdirSync(join(root, 'releases'))) {
|
|
162
|
+
if (!SKILL_HASH.test(id) || keep.has(id)) continue;
|
|
163
|
+
const release = skillRelease(root, id);
|
|
164
|
+
if (!lstatSync(release).isDirectory()) continue;
|
|
165
|
+
try {
|
|
166
|
+
if (oldRelease(release)) rmSync(release, { recursive: true });
|
|
167
|
+
} catch (err) { if (!isRecord(err) || err.code !== 'ENOENT') throw err; }
|
|
168
|
+
}
|
|
169
|
+
}
|