skyloom 1.16.1 → 1.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -3
- package/dist/cli/loom_chat.d.ts.map +1 -1
- package/dist/cli/loom_chat.js +17 -0
- package/dist/cli/loom_chat.js.map +1 -1
- package/dist/cli/main.js +37 -1
- package/dist/cli/main.js.map +1 -1
- package/dist/core/agent.d.ts +2 -0
- package/dist/core/agent.d.ts.map +1 -1
- package/dist/core/agent.js +25 -4
- package/dist/core/agent.js.map +1 -1
- package/dist/core/bgproc.d.ts +59 -0
- package/dist/core/bgproc.d.ts.map +1 -0
- package/dist/core/bgproc.js +135 -0
- package/dist/core/bgproc.js.map +1 -0
- package/dist/core/commands.d.ts.map +1 -1
- package/dist/core/commands.js +20 -0
- package/dist/core/commands.js.map +1 -1
- package/dist/core/diagnostics.d.ts +39 -0
- package/dist/core/diagnostics.d.ts.map +1 -0
- package/dist/core/diagnostics.js +206 -0
- package/dist/core/diagnostics.js.map +1 -0
- package/dist/core/diff.d.ts +31 -0
- package/dist/core/diff.d.ts.map +1 -0
- package/dist/core/diff.js +82 -0
- package/dist/core/diff.js.map +1 -0
- package/dist/core/envcontext.d.ts +25 -0
- package/dist/core/envcontext.d.ts.map +1 -0
- package/dist/core/envcontext.js +112 -0
- package/dist/core/envcontext.js.map +1 -0
- package/dist/core/factory.d.ts +2 -0
- package/dist/core/factory.d.ts.map +1 -1
- package/dist/core/factory.js +35 -2
- package/dist/core/factory.js.map +1 -1
- package/dist/core/sandbox.d.ts +1 -0
- package/dist/core/sandbox.d.ts.map +1 -1
- package/dist/core/sandbox.js +1 -0
- package/dist/core/sandbox.js.map +1 -1
- package/dist/core/security.d.ts +22 -2
- package/dist/core/security.d.ts.map +1 -1
- package/dist/core/security.js +54 -24
- package/dist/core/security.js.map +1 -1
- package/dist/core/skill.d.ts +4 -0
- package/dist/core/skill.d.ts.map +1 -1
- package/dist/core/skill.js +1 -0
- package/dist/core/skill.js.map +1 -1
- package/dist/core/subagent.d.ts +75 -0
- package/dist/core/subagent.d.ts.map +1 -0
- package/dist/core/subagent.js +287 -0
- package/dist/core/subagent.js.map +1 -0
- package/dist/core/tool.d.ts +25 -1
- package/dist/core/tool.d.ts.map +1 -1
- package/dist/core/tool.js +113 -36
- package/dist/core/tool.js.map +1 -1
- package/dist/plugins/loader.d.ts +49 -8
- package/dist/plugins/loader.d.ts.map +1 -1
- package/dist/plugins/loader.js +129 -16
- package/dist/plugins/loader.js.map +1 -1
- package/dist/tools/builtin.d.ts.map +1 -1
- package/dist/tools/builtin.js +126 -13
- package/dist/tools/builtin.js.map +1 -1
- package/dist/tools/spawn.d.ts +23 -0
- package/dist/tools/spawn.d.ts.map +1 -0
- package/dist/tools/spawn.js +77 -0
- package/dist/tools/spawn.js.map +1 -0
- package/docs/OPTIMIZATION_PLAN.md +21 -4
- package/package.json +1 -1
- package/src/cli/loom_chat.ts +11 -0
- package/src/cli/main.ts +31 -1
- package/src/core/agent.ts +25 -4
- package/src/core/bgproc.ts +153 -0
- package/src/core/commands.ts +20 -0
- package/src/core/diagnostics.ts +178 -0
- package/src/core/diff.ts +98 -0
- package/src/core/envcontext.ts +79 -0
- package/src/core/factory.ts +31 -2
- package/src/core/sandbox.ts +1 -1
- package/src/core/security.ts +62 -21
- package/src/core/skill.ts +1 -1
- package/src/core/subagent.ts +272 -0
- package/src/core/tool.ts +119 -40
- package/src/plugins/loader.ts +145 -18
- package/src/tools/builtin.ts +115 -13
- package/src/tools/spawn.ts +92 -0
- package/tests/agent.test.ts +35 -2
- package/tests/bgproc.test.ts +65 -0
- package/tests/diagnostics.test.ts +86 -0
- package/tests/edit_diff.test.ts +102 -0
- package/tests/envcontext.test.ts +67 -0
- package/tests/plugins.test.ts +84 -0
- package/tests/security.test.ts +87 -0
- package/tests/subagent.test.ts +211 -0
- package/tests/tool.test.ts +116 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Code diagnostics — the LSP capability that matters most to an agent: surface
|
|
3
|
+
* real type/lint errors (with line:col) so the model fixes root causes instead
|
|
4
|
+
* of guessing.
|
|
5
|
+
*
|
|
6
|
+
* Strategy, in order:
|
|
7
|
+
* 1. TS/JS → the TypeScript compiler API, resolved from the user's workspace
|
|
8
|
+
* node_modules (then sky's own). Real semantic diagnostics, no
|
|
9
|
+
* language server to install.
|
|
10
|
+
* 2. other → a configured external checker command (config.diagnostics map
|
|
11
|
+
* of `ext -> "cmd {file}"`), output parsed for `file:line:col msg`.
|
|
12
|
+
*
|
|
13
|
+
* This is intentionally not a full LSP client (hover/goto/rename); it delivers
|
|
14
|
+
* the diagnostics that close the agent's edit→verify loop on a per-file basis.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import * as fs from 'fs';
|
|
18
|
+
import * as path from 'path';
|
|
19
|
+
import { execSync } from 'child_process';
|
|
20
|
+
import { getLogger } from './logger';
|
|
21
|
+
|
|
22
|
+
const log = getLogger('diagnostics');
|
|
23
|
+
|
|
24
|
+
export type Severity = 'error' | 'warning' | 'info';
|
|
25
|
+
|
|
26
|
+
export interface Diagnostic {
|
|
27
|
+
line: number; // 1-based
|
|
28
|
+
column: number; // 1-based
|
|
29
|
+
severity: Severity;
|
|
30
|
+
message: string;
|
|
31
|
+
code?: string;
|
|
32
|
+
source?: string; // 'ts' | external command name
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const TS_EXTS = new Set(['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs']);
|
|
36
|
+
|
|
37
|
+
/** Resolve the TypeScript module the way an LSP would: workspace first. */
|
|
38
|
+
function loadTypescript(cwd: string): any | null {
|
|
39
|
+
const bases = [cwd, process.cwd(), __dirname];
|
|
40
|
+
for (const base of bases) {
|
|
41
|
+
try {
|
|
42
|
+
const p = require.resolve('typescript', { paths: [base] });
|
|
43
|
+
return require(p);
|
|
44
|
+
} catch { /* try next */ }
|
|
45
|
+
}
|
|
46
|
+
try { return require('typescript'); } catch { return null; }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function findNearest(file: string, name: string): string | null {
|
|
50
|
+
let dir = path.dirname(path.resolve(file));
|
|
51
|
+
for (let i = 0; i < 40; i++) {
|
|
52
|
+
const candidate = path.join(dir, name);
|
|
53
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
54
|
+
const parent = path.dirname(dir);
|
|
55
|
+
if (parent === dir) break;
|
|
56
|
+
dir = parent;
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Semantic + syntactic diagnostics for one TS/JS file via the compiler API. */
|
|
62
|
+
export function getTypeScriptDiagnostics(file: string, cwd: string = process.cwd()): Diagnostic[] | { unavailable: string } {
|
|
63
|
+
const ts = loadTypescript(cwd);
|
|
64
|
+
if (!ts) return { unavailable: 'typescript not installed in workspace or sky — cannot type-check.' };
|
|
65
|
+
|
|
66
|
+
const abs = path.resolve(file);
|
|
67
|
+
let options: any = { allowJs: true, checkJs: false, noEmit: true, skipLibCheck: true };
|
|
68
|
+
let fileNames: string[] = [abs];
|
|
69
|
+
|
|
70
|
+
const tsconfig = findNearest(abs, 'tsconfig.json');
|
|
71
|
+
if (tsconfig) {
|
|
72
|
+
try {
|
|
73
|
+
const read = ts.readConfigFile(tsconfig, ts.sys.readFile);
|
|
74
|
+
const parsed = ts.parseJsonConfigFileContent(read.config || {}, ts.sys, path.dirname(tsconfig));
|
|
75
|
+
options = { ...parsed.options, noEmit: true };
|
|
76
|
+
// Keep the project's file set so cross-file types resolve, but ensure our
|
|
77
|
+
// target is included.
|
|
78
|
+
fileNames = parsed.fileNames.includes(abs) ? parsed.fileNames : [...parsed.fileNames, abs];
|
|
79
|
+
} catch (e) {
|
|
80
|
+
log.warn('tsconfig_parse_failed', { tsconfig, error: String(e) });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let program: any;
|
|
85
|
+
try {
|
|
86
|
+
program = ts.createProgram(fileNames, options);
|
|
87
|
+
} catch (e) {
|
|
88
|
+
return { unavailable: `failed to build TypeScript program: ${e}` };
|
|
89
|
+
}
|
|
90
|
+
const source = program.getSourceFile(abs);
|
|
91
|
+
if (!source) return { unavailable: `file not part of the TypeScript program: ${abs}` };
|
|
92
|
+
|
|
93
|
+
const raw = [
|
|
94
|
+
...program.getSyntacticDiagnostics(source),
|
|
95
|
+
...program.getSemanticDiagnostics(source),
|
|
96
|
+
];
|
|
97
|
+
|
|
98
|
+
const out: Diagnostic[] = [];
|
|
99
|
+
for (const d of raw) {
|
|
100
|
+
const message = ts.flattenDiagnosticMessageText(d.messageText, '\n');
|
|
101
|
+
let line = 1, column = 1;
|
|
102
|
+
if (d.file && typeof d.start === 'number') {
|
|
103
|
+
const pos = d.file.getLineAndCharacterOfPosition(d.start);
|
|
104
|
+
line = pos.line + 1;
|
|
105
|
+
column = pos.character + 1;
|
|
106
|
+
}
|
|
107
|
+
const severity: Severity = d.category === 1 ? 'error' : d.category === 0 ? 'warning' : 'info';
|
|
108
|
+
out.push({ line, column, severity, message, code: d.code ? `TS${d.code}` : undefined, source: 'ts' });
|
|
109
|
+
}
|
|
110
|
+
out.sort((a, b) => a.line - b.line || a.column - b.column);
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Parse generic `path:line:col: message` style compiler/linter output. */
|
|
115
|
+
export function parseDiagnosticOutput(output: string, source: string): Diagnostic[] {
|
|
116
|
+
const out: Diagnostic[] = [];
|
|
117
|
+
const re = /^(.*?):(\d+):(\d+):?\s*(error|warning|info)?:?\s*(.*)$/gim;
|
|
118
|
+
let m: RegExpExecArray | null;
|
|
119
|
+
while ((m = re.exec(output)) !== null) {
|
|
120
|
+
const sev = (m[4] || 'error').toLowerCase() as Severity;
|
|
121
|
+
out.push({
|
|
122
|
+
line: parseInt(m[2], 10) || 1,
|
|
123
|
+
column: parseInt(m[3], 10) || 1,
|
|
124
|
+
severity: sev === 'warning' || sev === 'info' ? sev : 'error',
|
|
125
|
+
message: (m[5] || '').trim(),
|
|
126
|
+
source,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Run a configured external checker for a non-TS file. */
|
|
133
|
+
function getExternalDiagnostics(file: string, command: string): Diagnostic[] | { unavailable: string } {
|
|
134
|
+
const cmd = command.includes('{file}')
|
|
135
|
+
? command.replace(/\{file\}/g, JSON.stringify(file))
|
|
136
|
+
: `${command} ${JSON.stringify(file)}`;
|
|
137
|
+
let output = '';
|
|
138
|
+
try {
|
|
139
|
+
output = execSync(cmd, { encoding: 'utf8', timeout: 60000, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
140
|
+
} catch (e: any) {
|
|
141
|
+
// Linters exit non-zero when they find problems — that's the normal path.
|
|
142
|
+
output = `${e.stdout || ''}\n${e.stderr || ''}`;
|
|
143
|
+
}
|
|
144
|
+
return parseDiagnosticOutput(output, command.split(/\s+/)[0]);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Get diagnostics for a file. `config.diagnostics` is an optional map of
|
|
149
|
+
* `ext -> command` for non-TS languages (e.g. { py: "ruff check {file}" }).
|
|
150
|
+
*/
|
|
151
|
+
export function getDiagnostics(file: string, config?: any, cwd: string = process.cwd()): Diagnostic[] | { unavailable: string } {
|
|
152
|
+
const abs = path.resolve(file);
|
|
153
|
+
if (!fs.existsSync(abs)) return { unavailable: `file not found: ${abs}` };
|
|
154
|
+
|
|
155
|
+
const ext = path.extname(abs).toLowerCase();
|
|
156
|
+
const map = (config?.diagnostics || {}) as Record<string, string>;
|
|
157
|
+
const extKey = ext.replace(/^\./, '');
|
|
158
|
+
|
|
159
|
+
// Explicit user config wins.
|
|
160
|
+
if (map[extKey]) return getExternalDiagnostics(abs, map[extKey]);
|
|
161
|
+
if (TS_EXTS.has(ext)) return getTypeScriptDiagnostics(abs, cwd);
|
|
162
|
+
|
|
163
|
+
return { unavailable: `no diagnostics provider for '${ext}'. Configure one in config.yaml diagnostics: { ${extKey || 'ext'}: "<checker> {file}" }` };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function formatDiagnostics(file: string, diags: Diagnostic[]): string {
|
|
167
|
+
if (diags.length === 0) return `✓ ${file} — no diagnostics (clean).`;
|
|
168
|
+
const errs = diags.filter(d => d.severity === 'error').length;
|
|
169
|
+
const warns = diags.filter(d => d.severity === 'warning').length;
|
|
170
|
+
const head = `${file} — ${errs} error${errs !== 1 ? 's' : ''}, ${warns} warning${warns !== 1 ? 's' : ''}:`;
|
|
171
|
+
const lines = diags.slice(0, 100).map(d => {
|
|
172
|
+
const mark = d.severity === 'error' ? '✗' : d.severity === 'warning' ? '⚠' : 'ℹ';
|
|
173
|
+
const code = d.code ? ` ${d.code}` : '';
|
|
174
|
+
return ` ${mark} ${d.line}:${d.column}${code} — ${d.message.replace(/\n/g, ' ')}`;
|
|
175
|
+
});
|
|
176
|
+
const more = diags.length > 100 ? `\n …and ${diags.length - 100} more` : '';
|
|
177
|
+
return `${head}\n${lines.join('\n')}${more}`;
|
|
178
|
+
}
|
package/src/core/diff.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal line-based unified diff for edit previews.
|
|
3
|
+
*
|
|
4
|
+
* Edits applied by edit_file are localized (a contiguous region changes), so a
|
|
5
|
+
* single trimmed hunk — common prefix/suffix removed, the differing middle
|
|
6
|
+
* shown with a few lines of context — is enough to let the model and the user
|
|
7
|
+
* see exactly what changed without diffing whole files line-by-line.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface DiffOptions {
|
|
11
|
+
/** Lines of unchanged context around the change (default 3). */
|
|
12
|
+
context?: number;
|
|
13
|
+
/** Optional path shown in the diff header. */
|
|
14
|
+
path?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface DiffStat {
|
|
18
|
+
added: number;
|
|
19
|
+
removed: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Result of rendering a diff: the text plus +/- line counts. */
|
|
23
|
+
export interface DiffResult {
|
|
24
|
+
text: string;
|
|
25
|
+
stat: DiffStat;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function commonPrefixLen(a: string[], b: string[]): number {
|
|
29
|
+
const n = Math.min(a.length, b.length);
|
|
30
|
+
let i = 0;
|
|
31
|
+
while (i < n && a[i] === b[i]) i++;
|
|
32
|
+
return i;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function commonSuffixLen(a: string[], b: string[], skip: number): number {
|
|
36
|
+
const max = Math.min(a.length, b.length) - skip;
|
|
37
|
+
let i = 0;
|
|
38
|
+
while (i < max && a[a.length - 1 - i] === b[b.length - 1 - i]) i++;
|
|
39
|
+
return i;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Produce a compact unified diff between two strings. Returns the diff text and
|
|
44
|
+
* a +/- line stat. Identical inputs yield an empty diff (stat 0/0).
|
|
45
|
+
*/
|
|
46
|
+
export function unifiedDiff(oldStr: string, newStr: string, opts: DiffOptions = {}): DiffResult {
|
|
47
|
+
if (oldStr === newStr) return { text: '', stat: { added: 0, removed: 0 } };
|
|
48
|
+
|
|
49
|
+
const context = Math.max(0, opts.context ?? 3);
|
|
50
|
+
const oldLines = oldStr.split('\n');
|
|
51
|
+
const newLines = newStr.split('\n');
|
|
52
|
+
|
|
53
|
+
let pre = commonPrefixLen(oldLines, newLines);
|
|
54
|
+
const suf = commonSuffixLen(oldLines, newLines, pre);
|
|
55
|
+
|
|
56
|
+
// The changed region (exclusive of the common prefix/suffix).
|
|
57
|
+
const oldChanged = oldLines.slice(pre, oldLines.length - suf);
|
|
58
|
+
const newChanged = newLines.slice(pre, newLines.length - suf);
|
|
59
|
+
|
|
60
|
+
// Context window bounds.
|
|
61
|
+
const ctxStart = Math.max(0, pre - context);
|
|
62
|
+
const oldCtxAfterStart = oldLines.length - suf;
|
|
63
|
+
const newCtxAfterStart = newLines.length - suf;
|
|
64
|
+
const oldCtxAfter = oldLines.slice(oldCtxAfterStart, oldCtxAfterStart + context);
|
|
65
|
+
const leading = oldLines.slice(ctxStart, pre);
|
|
66
|
+
|
|
67
|
+
const lines: string[] = [];
|
|
68
|
+
if (opts.path) lines.push(`--- ${opts.path}`, `+++ ${opts.path}`);
|
|
69
|
+
|
|
70
|
+
// Hunk header (1-based line numbers).
|
|
71
|
+
const oldStart = ctxStart + 1;
|
|
72
|
+
const oldCount = leading.length + oldChanged.length + oldCtxAfter.length;
|
|
73
|
+
const newStart = ctxStart + 1;
|
|
74
|
+
const newCount = leading.length + newChanged.length + oldCtxAfter.length;
|
|
75
|
+
lines.push(`@@ -${oldStart},${oldCount} +${newStart},${newCount} @@`);
|
|
76
|
+
|
|
77
|
+
for (const l of leading) lines.push(` ${l}`);
|
|
78
|
+
for (const l of oldChanged) lines.push(`-${l}`);
|
|
79
|
+
for (const l of newChanged) lines.push(`+${l}`);
|
|
80
|
+
for (const l of oldCtxAfter) lines.push(` ${l}`);
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
text: lines.join('\n'),
|
|
84
|
+
stat: { added: newChanged.length, removed: oldChanged.length },
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Count non-overlapping occurrences of `needle` in `haystack`. */
|
|
89
|
+
export function countOccurrences(haystack: string, needle: string): number {
|
|
90
|
+
if (!needle) return 0;
|
|
91
|
+
let count = 0;
|
|
92
|
+
let idx = haystack.indexOf(needle);
|
|
93
|
+
while (idx !== -1) {
|
|
94
|
+
count++;
|
|
95
|
+
idx = haystack.indexOf(needle, idx + needle.length);
|
|
96
|
+
}
|
|
97
|
+
return count;
|
|
98
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Environment context snapshot — a small, model-visible block describing the
|
|
3
|
+
* runtime world (working directory, platform, git, Node, date), kept separate
|
|
4
|
+
* from conversation history. Mirrors Claude Code's <env> block so the agent
|
|
5
|
+
* grounds itself without having to probe with tools every turn.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as fs from 'fs';
|
|
9
|
+
import * as os from 'os';
|
|
10
|
+
import * as path from 'path';
|
|
11
|
+
|
|
12
|
+
export interface GitInfo {
|
|
13
|
+
repo: boolean;
|
|
14
|
+
branch?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Cheap git detection: walk up for a `.git` (handles worktrees, where `.git`
|
|
19
|
+
* is a file pointing at the real gitdir), then read HEAD for the branch. No
|
|
20
|
+
* subprocess — just file reads.
|
|
21
|
+
*/
|
|
22
|
+
export function gitInfo(cwd: string = process.cwd()): GitInfo {
|
|
23
|
+
let dir = path.resolve(cwd);
|
|
24
|
+
for (let i = 0; i < 40; i++) {
|
|
25
|
+
const dotGit = path.join(dir, '.git');
|
|
26
|
+
if (fs.existsSync(dotGit)) {
|
|
27
|
+
let gitDir = dotGit;
|
|
28
|
+
try {
|
|
29
|
+
if (fs.statSync(dotGit).isFile()) {
|
|
30
|
+
const m = fs.readFileSync(dotGit, 'utf8').match(/gitdir:\s*(.+)/);
|
|
31
|
+
if (m) gitDir = path.resolve(dir, m[1].trim());
|
|
32
|
+
}
|
|
33
|
+
} catch { /* treat as repo without branch */ }
|
|
34
|
+
try {
|
|
35
|
+
const head = fs.readFileSync(path.join(gitDir, 'HEAD'), 'utf8').trim();
|
|
36
|
+
const ref = head.match(/ref:\s*refs\/heads\/(.+)/);
|
|
37
|
+
return { repo: true, branch: ref ? ref[1] : head.slice(0, 8) };
|
|
38
|
+
} catch {
|
|
39
|
+
return { repo: true };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const parent = path.dirname(dir);
|
|
43
|
+
if (parent === dir) break;
|
|
44
|
+
dir = parent;
|
|
45
|
+
}
|
|
46
|
+
return { repo: false };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Build the environment block. `now` is injectable for deterministic tests.
|
|
51
|
+
*/
|
|
52
|
+
export function buildEnvBlock(opts?: { cwd?: string; lang?: string; now?: Date }): string {
|
|
53
|
+
const cwd = opts?.cwd || process.cwd();
|
|
54
|
+
const lang = opts?.lang || 'zh';
|
|
55
|
+
const now = opts?.now || new Date();
|
|
56
|
+
const git = gitInfo(cwd);
|
|
57
|
+
const date = now.toISOString().slice(0, 10);
|
|
58
|
+
const platform = `${process.platform} ${os.release()}`;
|
|
59
|
+
const gitLine = git.repo ? (git.branch ? `yes (branch: ${git.branch})` : 'yes') : 'no';
|
|
60
|
+
|
|
61
|
+
if (lang === 'en') {
|
|
62
|
+
return [
|
|
63
|
+
'## Environment',
|
|
64
|
+
`- Working directory: ${cwd}`,
|
|
65
|
+
`- Platform: ${platform}`,
|
|
66
|
+
`- Node: ${process.version}`,
|
|
67
|
+
`- Git repo: ${gitLine}`,
|
|
68
|
+
`- Date: ${date}`,
|
|
69
|
+
].join('\n');
|
|
70
|
+
}
|
|
71
|
+
return [
|
|
72
|
+
'## 运行环境',
|
|
73
|
+
`- 工作目录: ${cwd}`,
|
|
74
|
+
`- 平台: ${platform}`,
|
|
75
|
+
`- Node: ${process.version}`,
|
|
76
|
+
`- Git 仓库: ${gitLine === 'no' ? '否' : gitLine.replace('yes', '是')}`,
|
|
77
|
+
`- 日期: ${date}`,
|
|
78
|
+
].join('\n');
|
|
79
|
+
}
|
package/src/core/factory.ts
CHANGED
|
@@ -23,6 +23,7 @@ export class SystemContext {
|
|
|
23
23
|
workspacePath: string = '';
|
|
24
24
|
mcp: any = null;
|
|
25
25
|
mcpStatus: string[] = [];
|
|
26
|
+
plugins: any = null;
|
|
26
27
|
|
|
27
28
|
constructor(opts: {
|
|
28
29
|
config: ReturnType<typeof loadConfig>;
|
|
@@ -33,6 +34,7 @@ export class SystemContext {
|
|
|
33
34
|
workspacePath?: string;
|
|
34
35
|
mcp?: any;
|
|
35
36
|
mcpStatus?: string[];
|
|
37
|
+
plugins?: any;
|
|
36
38
|
}) {
|
|
37
39
|
this.config = opts.config;
|
|
38
40
|
this.bus = opts.bus;
|
|
@@ -42,9 +44,15 @@ export class SystemContext {
|
|
|
42
44
|
this.workspacePath = opts.workspacePath || '';
|
|
43
45
|
this.mcp = opts.mcp || null;
|
|
44
46
|
this.mcpStatus = opts.mcpStatus || [];
|
|
47
|
+
this.plugins = opts.plugins || null;
|
|
45
48
|
}
|
|
46
49
|
|
|
47
50
|
async initAll(): Promise<void> {
|
|
51
|
+
// Plugin `init` hook — fires once after load, before agents come up.
|
|
52
|
+
if (this.plugins) {
|
|
53
|
+
try { await this.plugins.emit('init', { config: this.config }); }
|
|
54
|
+
catch (e) { log.warn('plugin_init_hook_failed', { error: String(e) }); }
|
|
55
|
+
}
|
|
48
56
|
if (this.mcp) {
|
|
49
57
|
try {
|
|
50
58
|
this.mcpStatus = await this.mcp.connectAll();
|
|
@@ -61,6 +69,11 @@ export class SystemContext {
|
|
|
61
69
|
}
|
|
62
70
|
|
|
63
71
|
async closeAll(): Promise<void> {
|
|
72
|
+
// Terminate any background shell jobs started this session.
|
|
73
|
+
try {
|
|
74
|
+
const { getBackgroundManager } = require('./bgproc');
|
|
75
|
+
getBackgroundManager().killAll();
|
|
76
|
+
} catch { /* best-effort */ }
|
|
64
77
|
for (const agent of this.agentMap.values()) {
|
|
65
78
|
await agent.close();
|
|
66
79
|
}
|
|
@@ -114,10 +127,11 @@ export function createSystemContext(): SystemContext {
|
|
|
114
127
|
log.warn('skills_not_available', { error: String(e) });
|
|
115
128
|
}
|
|
116
129
|
|
|
117
|
-
// Load plugins
|
|
130
|
+
// Load plugins (ordered hook lifecycle — see plugins/loader)
|
|
131
|
+
let pluginLoader: any = null;
|
|
118
132
|
try {
|
|
119
133
|
const { PluginLoader } = require('../plugins/loader');
|
|
120
|
-
|
|
134
|
+
pluginLoader = new PluginLoader(baseToolRegistry, config);
|
|
121
135
|
const pluginConfig = (config as any).plugins;
|
|
122
136
|
const pluginDirs = pluginConfig?.enabled ? (pluginConfig.directories || []) : [];
|
|
123
137
|
pluginLoader.loadFromDirectories(pluginDirs);
|
|
@@ -195,6 +209,20 @@ export function createSystemContext(): SystemContext {
|
|
|
195
209
|
log.warn('delegate_tool_not_available', { agent: name, error: String(e) });
|
|
196
210
|
}
|
|
197
211
|
|
|
212
|
+
// Register the spawn_agent tool — isolated-context subagents (Task tool).
|
|
213
|
+
try {
|
|
214
|
+
const { createSpawnAgentTool } = require('../tools/spawn');
|
|
215
|
+
agentRegistry.register(createSpawnAgentTool({
|
|
216
|
+
config,
|
|
217
|
+
llm,
|
|
218
|
+
bus,
|
|
219
|
+
baseToolRegistry,
|
|
220
|
+
baseSkillRegistry,
|
|
221
|
+
}));
|
|
222
|
+
} catch (e) {
|
|
223
|
+
log.warn('spawn_tool_not_available', { agent: name, error: String(e) });
|
|
224
|
+
}
|
|
225
|
+
|
|
198
226
|
// Register model self-service tools (list_models / set_my_model)
|
|
199
227
|
try {
|
|
200
228
|
const { createModelTools } = require('../tools/model_tool');
|
|
@@ -234,6 +262,7 @@ export function createSystemContext(): SystemContext {
|
|
|
234
262
|
toolRegistry: baseToolRegistry,
|
|
235
263
|
workspacePath,
|
|
236
264
|
mcp: mcpManager,
|
|
265
|
+
plugins: pluginLoader,
|
|
237
266
|
});
|
|
238
267
|
}
|
|
239
268
|
|
package/src/core/sandbox.ts
CHANGED
|
@@ -44,7 +44,7 @@ function cleanup(dir: string): void {
|
|
|
44
44
|
/* ═══════════════════════════════════════
|
|
45
45
|
Pre-execution check
|
|
46
46
|
═══════════════════════════════════════ */
|
|
47
|
-
function preflightCheck(command: string): string | null {
|
|
47
|
+
export function preflightCheck(command: string): string | null {
|
|
48
48
|
if (!command || !command.trim()) return "Empty command";
|
|
49
49
|
|
|
50
50
|
const lower = command.toLowerCase().trim();
|
package/src/core/security.ts
CHANGED
|
@@ -137,20 +137,75 @@ export interface AuditEntry {
|
|
|
137
137
|
/* ═══════════════════════════════════════
|
|
138
138
|
Security context — per-session security state
|
|
139
139
|
═══════════════════════════════════════ */
|
|
140
|
+
/**
|
|
141
|
+
* Permission modes (Claude Code parity):
|
|
142
|
+
* - strict deny every non-SAFE tool (read-only-ish lockdown)
|
|
143
|
+
* - interactive ask before every non-SAFE tool ("default")
|
|
144
|
+
* - auto allow LOW, ask MEDIUM/HIGH, deny CRITICAL
|
|
145
|
+
* - acceptEdits auto-accept file-edit tools, otherwise behave like auto
|
|
146
|
+
* - bypass allow everything except red-line patterns ("yolo")
|
|
147
|
+
*/
|
|
148
|
+
export type ApprovalMode = "auto" | "interactive" | "strict" | "acceptEdits" | "bypass";
|
|
149
|
+
export type Decision = "allow" | "ask" | "deny";
|
|
150
|
+
|
|
151
|
+
/** User-facing aliases → canonical permission mode (for /perm and config). */
|
|
152
|
+
export const PERMISSION_MODE_ALIASES: Record<string, ApprovalMode> = {
|
|
153
|
+
default: "interactive",
|
|
154
|
+
interactive: "interactive",
|
|
155
|
+
ask: "interactive",
|
|
156
|
+
auto: "auto",
|
|
157
|
+
accept: "acceptEdits",
|
|
158
|
+
acceptedits: "acceptEdits",
|
|
159
|
+
edits: "acceptEdits",
|
|
160
|
+
strict: "strict",
|
|
161
|
+
readonly: "strict",
|
|
162
|
+
bypass: "bypass",
|
|
163
|
+
yolo: "bypass",
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
/** Tools that mutate the filesystem — the ones acceptEdits waves through. */
|
|
167
|
+
const EDIT_TOOL_RE = /^(write_|edit_|append_|replace_|create_|make_|copy_|move_|delete_)/;
|
|
168
|
+
export function isEditTool(toolName: string): boolean { return EDIT_TOOL_RE.test(toolName); }
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Pure permission decision (red-line is gated separately, before this). Keeps
|
|
172
|
+
* the ask/allow/deny semantics in one testable place across all modes.
|
|
173
|
+
*/
|
|
174
|
+
export function decideApproval(level: DangerLevel, mode: ApprovalMode, toolName: string): Decision {
|
|
175
|
+
if (level === DangerLevel.SAFE) return "allow";
|
|
176
|
+
switch (mode) {
|
|
177
|
+
case "bypass": return "allow";
|
|
178
|
+
case "strict": return "deny";
|
|
179
|
+
case "interactive": return "ask";
|
|
180
|
+
case "acceptEdits":
|
|
181
|
+
if (level === DangerLevel.CRITICAL) return "deny";
|
|
182
|
+
if (isEditTool(toolName)) return "allow";
|
|
183
|
+
return level <= DangerLevel.LOW ? "allow" : "ask";
|
|
184
|
+
case "auto":
|
|
185
|
+
default:
|
|
186
|
+
if (level <= DangerLevel.LOW) return "allow";
|
|
187
|
+
if (level === DangerLevel.CRITICAL) return "deny";
|
|
188
|
+
return "ask";
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
140
192
|
export class SecurityContext {
|
|
141
193
|
public auditLog: AuditEntry[] = [];
|
|
142
194
|
public deniedCount = 0;
|
|
143
195
|
public autoApprovedCount = 0;
|
|
144
196
|
public manualApprovedCount = 0;
|
|
145
|
-
public approvalMode:
|
|
197
|
+
public approvalMode: ApprovalMode = "auto";
|
|
146
198
|
|
|
147
199
|
private approvalCallback: ((tool: string, args: Record<string, any>, level: DangerLevel) => Promise<boolean>) | null = null;
|
|
148
200
|
|
|
149
|
-
constructor(opts?: { mode?:
|
|
201
|
+
constructor(opts?: { mode?: ApprovalMode; onApprove?: (tool: string, args: Record<string, any>, level: DangerLevel) => Promise<boolean> }) {
|
|
150
202
|
if (opts?.mode) this.approvalMode = opts.mode;
|
|
151
203
|
if (opts?.onApprove) this.approvalCallback = opts.onApprove;
|
|
152
204
|
}
|
|
153
205
|
|
|
206
|
+
/** Switch the active permission mode at runtime. */
|
|
207
|
+
setMode(mode: ApprovalMode): void { this.approvalMode = mode; }
|
|
208
|
+
|
|
154
209
|
/** Get the danger level for a tool. Defaults to SAFE for unknown tools. */
|
|
155
210
|
getDangerLevel(toolName: string): DangerLevel {
|
|
156
211
|
return TOOL_DANGER_MAP[toolName] ?? DangerLevel.SAFE;
|
|
@@ -180,27 +235,13 @@ export class SecurityContext {
|
|
|
180
235
|
return [false, redline];
|
|
181
236
|
}
|
|
182
237
|
|
|
183
|
-
|
|
184
|
-
if (
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
if (this.approvalMode === "strict") {
|
|
188
|
-
return [false, `Strict mode: tool '${toolName}' (level ${level}) requires manual approval`];
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
// Auto mode — allow LOW, prompt for MEDIUM+, deny CRITICAL
|
|
192
|
-
if (this.approvalMode === "auto") {
|
|
193
|
-
if (level <= DangerLevel.LOW) return [true, "auto-low"];
|
|
194
|
-
if (level === DangerLevel.CRITICAL) return [false, `CRITICAL tool '${toolName}' requires explicit human approval`];
|
|
195
|
-
// MEDIUM/HIGH with auto mode => need callback
|
|
196
|
-
if (this.approvalCallback) {
|
|
197
|
-
const approved = await this.approvalCallback(toolName, args, level);
|
|
198
|
-
return [approved, approved ? "user-approved" : "user-denied"];
|
|
199
|
-
}
|
|
200
|
-
return [true, "auto-med"]; // no callback → auto-allow but log
|
|
238
|
+
const decision = decideApproval(level, this.approvalMode, toolName);
|
|
239
|
+
if (decision === "allow") return [true, level === DangerLevel.SAFE ? "safe" : `${this.approvalMode}-allow`];
|
|
240
|
+
if (decision === "deny") {
|
|
241
|
+
return [false, `${this.approvalMode} mode: tool '${toolName}' (level ${level}) requires approval / is blocked`];
|
|
201
242
|
}
|
|
202
243
|
|
|
203
|
-
//
|
|
244
|
+
// decision === "ask" — defer to the interactive callback if present.
|
|
204
245
|
if (this.approvalCallback) {
|
|
205
246
|
const approved = await this.approvalCallback(toolName, args, level);
|
|
206
247
|
return [approved, approved ? "user-approved" : "user-denied"];
|
package/src/core/skill.ts
CHANGED
|
@@ -210,7 +210,7 @@ function parseFrontmatter(text: string): { fm: Record<string, any>; body: string
|
|
|
210
210
|
/**
|
|
211
211
|
* Normalize a Claude Code tool name into sky's registry name.
|
|
212
212
|
*/
|
|
213
|
-
function normalizeClaudeToolName(raw: string): string {
|
|
213
|
+
export function normalizeClaudeToolName(raw: string): string {
|
|
214
214
|
let s = raw.trim();
|
|
215
215
|
if (!s) return s;
|
|
216
216
|
// Strip permission scoping: Bash(ls *) -> Bash
|