skyloom 1.16.2 → 1.18.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.
Files changed (109) hide show
  1. package/README.md +15 -3
  2. package/dist/cli/loom_chat.d.ts.map +1 -1
  3. package/dist/cli/loom_chat.js +17 -0
  4. package/dist/cli/loom_chat.js.map +1 -1
  5. package/dist/cli/main.js +37 -1
  6. package/dist/cli/main.js.map +1 -1
  7. package/dist/core/agent.d.ts +2 -0
  8. package/dist/core/agent.d.ts.map +1 -1
  9. package/dist/core/agent.js +21 -5
  10. package/dist/core/agent.js.map +1 -1
  11. package/dist/core/bgproc.d.ts +59 -0
  12. package/dist/core/bgproc.d.ts.map +1 -0
  13. package/dist/core/bgproc.js +135 -0
  14. package/dist/core/bgproc.js.map +1 -0
  15. package/dist/core/commands.d.ts.map +1 -1
  16. package/dist/core/commands.js +20 -0
  17. package/dist/core/commands.js.map +1 -1
  18. package/dist/core/diagnostics.d.ts +39 -0
  19. package/dist/core/diagnostics.d.ts.map +1 -0
  20. package/dist/core/diagnostics.js +206 -0
  21. package/dist/core/diagnostics.js.map +1 -0
  22. package/dist/core/diff.d.ts +31 -0
  23. package/dist/core/diff.d.ts.map +1 -0
  24. package/dist/core/diff.js +82 -0
  25. package/dist/core/diff.js.map +1 -0
  26. package/dist/core/envcontext.d.ts +25 -0
  27. package/dist/core/envcontext.d.ts.map +1 -0
  28. package/dist/core/envcontext.js +112 -0
  29. package/dist/core/envcontext.js.map +1 -0
  30. package/dist/core/factory.d.ts +2 -0
  31. package/dist/core/factory.d.ts.map +1 -1
  32. package/dist/core/factory.js +35 -2
  33. package/dist/core/factory.js.map +1 -1
  34. package/dist/core/patch.d.ts +59 -0
  35. package/dist/core/patch.d.ts.map +1 -0
  36. package/dist/core/patch.js +220 -0
  37. package/dist/core/patch.js.map +1 -0
  38. package/dist/core/protocol.d.ts +11 -0
  39. package/dist/core/protocol.d.ts.map +1 -0
  40. package/dist/core/protocol.js +39 -0
  41. package/dist/core/protocol.js.map +1 -0
  42. package/dist/core/sandbox.d.ts +1 -0
  43. package/dist/core/sandbox.d.ts.map +1 -1
  44. package/dist/core/sandbox.js +1 -0
  45. package/dist/core/sandbox.js.map +1 -1
  46. package/dist/core/search.d.ts +41 -0
  47. package/dist/core/search.d.ts.map +1 -0
  48. package/dist/core/search.js +156 -0
  49. package/dist/core/search.js.map +1 -0
  50. package/dist/core/security.d.ts +22 -2
  51. package/dist/core/security.d.ts.map +1 -1
  52. package/dist/core/security.js +55 -24
  53. package/dist/core/security.js.map +1 -1
  54. package/dist/core/skill.d.ts +4 -0
  55. package/dist/core/skill.d.ts.map +1 -1
  56. package/dist/core/skill.js +1 -0
  57. package/dist/core/skill.js.map +1 -1
  58. package/dist/core/subagent.d.ts +75 -0
  59. package/dist/core/subagent.d.ts.map +1 -0
  60. package/dist/core/subagent.js +287 -0
  61. package/dist/core/subagent.js.map +1 -0
  62. package/dist/core/tool.d.ts +23 -1
  63. package/dist/core/tool.d.ts.map +1 -1
  64. package/dist/core/tool.js +95 -30
  65. package/dist/core/tool.js.map +1 -1
  66. package/dist/plugins/loader.d.ts +49 -8
  67. package/dist/plugins/loader.d.ts.map +1 -1
  68. package/dist/plugins/loader.js +129 -16
  69. package/dist/plugins/loader.js.map +1 -1
  70. package/dist/tools/builtin.d.ts.map +1 -1
  71. package/dist/tools/builtin.js +183 -17
  72. package/dist/tools/builtin.js.map +1 -1
  73. package/dist/tools/spawn.d.ts +23 -0
  74. package/dist/tools/spawn.d.ts.map +1 -0
  75. package/dist/tools/spawn.js +77 -0
  76. package/dist/tools/spawn.js.map +1 -0
  77. package/docs/OPTIMIZATION_PLAN.md +21 -4
  78. package/package.json +1 -1
  79. package/src/cli/loom_chat.ts +11 -0
  80. package/src/cli/main.ts +31 -1
  81. package/src/core/agent.ts +20 -5
  82. package/src/core/bgproc.ts +153 -0
  83. package/src/core/commands.ts +20 -0
  84. package/src/core/diagnostics.ts +178 -0
  85. package/src/core/diff.ts +98 -0
  86. package/src/core/envcontext.ts +79 -0
  87. package/src/core/factory.ts +31 -2
  88. package/src/core/patch.ts +176 -0
  89. package/src/core/protocol.ts +36 -0
  90. package/src/core/sandbox.ts +1 -1
  91. package/src/core/search.ts +138 -0
  92. package/src/core/security.ts +63 -21
  93. package/src/core/skill.ts +1 -1
  94. package/src/core/subagent.ts +272 -0
  95. package/src/core/tool.ts +101 -31
  96. package/src/plugins/loader.ts +145 -18
  97. package/src/tools/builtin.ts +167 -17
  98. package/src/tools/spawn.ts +92 -0
  99. package/tests/bgproc.test.ts +65 -0
  100. package/tests/diagnostics.test.ts +86 -0
  101. package/tests/edit_diff.test.ts +102 -0
  102. package/tests/envcontext.test.ts +67 -0
  103. package/tests/patch.test.ts +128 -0
  104. package/tests/plugins.test.ts +84 -0
  105. package/tests/protocol.test.ts +27 -0
  106. package/tests/search.test.ts +87 -0
  107. package/tests/security.test.ts +87 -0
  108. package/tests/subagent.test.ts +211 -0
  109. package/tests/tool.test.ts +120 -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
+ }
@@ -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
+ }
@@ -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
- const pluginLoader = new PluginLoader(baseToolRegistry);
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
 
@@ -0,0 +1,176 @@
1
+ /**
2
+ * apply_patch — atomic, multi-file edits for larger refactors.
3
+ *
4
+ * Uses exact search/replace blocks (not line-number/context hunks), so it can't
5
+ * misapply against a drifted file: every SEARCH must match the current file
6
+ * content exactly and uniquely. The whole patch is validated first; disk is
7
+ * only touched once every operation is known to apply — so a bad block aborts
8
+ * the patch without leaving a half-applied tree.
9
+ *
10
+ * Format:
11
+ * *** Update File: path
12
+ * <<<<<<< SEARCH
13
+ * exact old text
14
+ * =======
15
+ * new text
16
+ * >>>>>>> REPLACE
17
+ * (one or more blocks per file)
18
+ *
19
+ * *** Add File: path
20
+ * full file content
21
+ *
22
+ * *** Delete File: path
23
+ *
24
+ * An optional `*** Begin Patch` / `*** End Patch` envelope is tolerated.
25
+ */
26
+
27
+ import * as fs from 'fs';
28
+ import * as path from 'path';
29
+ import { countOccurrences, unifiedDiff } from './diff';
30
+
31
+ export interface PatchBlock { search: string; replace: string; }
32
+ export type PatchOp =
33
+ | { op: 'update'; path: string; blocks: PatchBlock[] }
34
+ | { op: 'add'; path: string; content: string }
35
+ | { op: 'delete'; path: string };
36
+
37
+ const HDR_UPDATE = /^\*\*\* Update File:\s*(.+?)\s*$/;
38
+ const HDR_ADD = /^\*\*\* Add File:\s*(.+?)\s*$/;
39
+ const HDR_DELETE = /^\*\*\* Delete File:\s*(.+?)\s*$/;
40
+ const MARK_SEARCH = '<<<<<<< SEARCH';
41
+ const MARK_SEP = '=======';
42
+ const MARK_REPLACE = '>>>>>>> REPLACE';
43
+
44
+ export function parsePatch(text: string): { ops: PatchOp[] } | { error: string } {
45
+ const lines = text.split(/\r?\n/);
46
+ const ops: PatchOp[] = [];
47
+ let i = 0;
48
+
49
+ const isHeader = (l: string) => l.startsWith('*** ');
50
+
51
+ while (i < lines.length) {
52
+ const line = lines[i];
53
+ if (line.startsWith('*** Begin Patch') || line.startsWith('*** End Patch')) { i++; continue; }
54
+ if (line.trim() === '') { i++; continue; }
55
+
56
+ let m: RegExpMatchArray | null;
57
+ if ((m = line.match(HDR_UPDATE))) {
58
+ const filePath = m[1];
59
+ i++;
60
+ const blocks: PatchBlock[] = [];
61
+ while (i < lines.length && !isHeader(lines[i])) {
62
+ if (lines[i].trim() === '') { i++; continue; }
63
+ if (lines[i] !== MARK_SEARCH) {
64
+ return { error: `Malformed Update block for '${filePath}': expected '${MARK_SEARCH}', got ${JSON.stringify(lines[i].slice(0, 40))}` };
65
+ }
66
+ i++;
67
+ const search: string[] = [];
68
+ while (i < lines.length && lines[i] !== MARK_SEP) search.push(lines[i++]);
69
+ if (i >= lines.length) return { error: `Unterminated SEARCH (missing '${MARK_SEP}') for '${filePath}'` };
70
+ i++; // consume separator
71
+ const replace: string[] = [];
72
+ while (i < lines.length && lines[i] !== MARK_REPLACE) replace.push(lines[i++]);
73
+ if (i >= lines.length) return { error: `Unterminated REPLACE (missing '${MARK_REPLACE}') for '${filePath}'` };
74
+ i++; // consume replace marker
75
+ blocks.push({ search: search.join('\n'), replace: replace.join('\n') });
76
+ }
77
+ if (blocks.length === 0) return { error: `Update File '${filePath}' has no SEARCH/REPLACE blocks` };
78
+ ops.push({ op: 'update', path: filePath, blocks });
79
+ } else if ((m = line.match(HDR_ADD))) {
80
+ const filePath = m[1];
81
+ i++;
82
+ const content: string[] = [];
83
+ while (i < lines.length && !isHeader(lines[i])) content.push(lines[i++]);
84
+ while (content.length && content[content.length - 1] === '') content.pop();
85
+ ops.push({ op: 'add', path: filePath, content: content.length ? content.join('\n') + '\n' : '' });
86
+ } else if ((m = line.match(HDR_DELETE))) {
87
+ ops.push({ op: 'delete', path: m[1] });
88
+ i++;
89
+ } else {
90
+ return { error: `Unexpected line outside any file section: ${JSON.stringify(line.slice(0, 60))}` };
91
+ }
92
+ }
93
+
94
+ if (ops.length === 0) return { error: 'Patch contains no operations.' };
95
+ return { ops };
96
+ }
97
+
98
+ export interface ApplyOptions {
99
+ cwd?: string;
100
+ /** Optional workspace-fence check; return a non-null string to abort. */
101
+ fenceCheck?: (abs: string) => string | null;
102
+ /** Optional pre-write snapshot hook (for /rewind). */
103
+ snapshot?: (abs: string) => void;
104
+ }
105
+
106
+ interface PlannedChange {
107
+ op: 'update' | 'add' | 'delete';
108
+ path: string;
109
+ abs: string;
110
+ oldContent?: string;
111
+ newContent?: string;
112
+ }
113
+
114
+ /**
115
+ * Validate every operation, then apply them all. Returns a human summary on
116
+ * success or an `Error: …` string on the first validation failure (no writes).
117
+ */
118
+ export function applyPatch(text: string, opts: ApplyOptions = {}): string {
119
+ const cwd = opts.cwd || process.cwd();
120
+ const parsed = parsePatch(text);
121
+ if ('error' in parsed) return `Error: ${parsed.error}`;
122
+
123
+ const plan: PlannedChange[] = [];
124
+
125
+ // ── Validate everything first (no disk mutation) ──
126
+ for (const op of parsed.ops) {
127
+ const abs = path.resolve(cwd, op.path);
128
+ if (opts.fenceCheck) { const f = opts.fenceCheck(abs); if (f) return f; }
129
+
130
+ if (op.op === 'update') {
131
+ if (!fs.existsSync(abs)) return `Error: Update target not found: ${op.path}`;
132
+ let content: string;
133
+ try { content = fs.readFileSync(abs, 'utf8'); } catch (e) { return `Error: cannot read ${op.path}: ${e}`; }
134
+ const orig = content;
135
+ for (const block of op.blocks) {
136
+ if (block.search === block.replace) return `Error: a SEARCH/REPLACE block for ${op.path} is a no-op (identical).`;
137
+ const n = countOccurrences(content, block.search);
138
+ if (n === 0) return `Error: SEARCH block not found in ${op.path}: ${JSON.stringify(block.search.slice(0, 80))}`;
139
+ if (n > 1) return `Error: SEARCH block is ambiguous in ${op.path} (appears ${n} times) — add more context to make it unique.`;
140
+ content = content.replace(block.search, () => block.replace); // literal replacement
141
+ }
142
+ plan.push({ op: 'update', path: op.path, abs, oldContent: orig, newContent: content });
143
+ } else if (op.op === 'add') {
144
+ if (fs.existsSync(abs)) return `Error: Add target already exists: ${op.path} (use Update File to modify it)`;
145
+ plan.push({ op: 'add', path: op.path, abs, newContent: op.content });
146
+ } else {
147
+ if (!fs.existsSync(abs)) return `Error: Delete target not found: ${op.path}`;
148
+ plan.push({ op: 'delete', path: op.path, abs });
149
+ }
150
+ }
151
+
152
+ // ── Apply (validation passed for all ops) ──
153
+ const summary: string[] = [];
154
+ for (const p of plan) {
155
+ try {
156
+ if (p.op === 'update') {
157
+ opts.snapshot?.(p.abs);
158
+ fs.writeFileSync(p.abs, p.newContent!, 'utf8');
159
+ const d = unifiedDiff(p.oldContent!, p.newContent!, { context: 0 });
160
+ summary.push(`~ ${p.path} (+${d.stat.added} -${d.stat.removed})`);
161
+ } else if (p.op === 'add') {
162
+ fs.mkdirSync(path.dirname(p.abs), { recursive: true });
163
+ fs.writeFileSync(p.abs, p.newContent!, 'utf8');
164
+ summary.push(`+ ${p.path} (new)`);
165
+ } else {
166
+ opts.snapshot?.(p.abs);
167
+ fs.unlinkSync(p.abs);
168
+ summary.push(`- ${p.path} (deleted)`);
169
+ }
170
+ } catch (e) {
171
+ return `Error: patch validated but failed while writing ${p.path}: ${e}\nPartial summary:\n${summary.join('\n')}`;
172
+ }
173
+ }
174
+
175
+ return `Applied patch — ${plan.length} file${plan.length !== 1 ? 's' : ''}:\n${summary.join('\n')}`;
176
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Engineering protocol — the working discipline injected into every agent's
3
+ * system prompt so it operates like a senior engineer, not just a code typist.
4
+ *
5
+ * Kept as a pure function (no `this`) so it can be unit-tested and evolved in
6
+ * one place. It deliberately names the project's own capabilities
7
+ * (code_search / get_diagnostics / run_bash) so the model actually uses the
8
+ * read→edit→verify loop instead of guessing.
9
+ */
10
+
11
+ export function engineeringProtocol(lang: string = 'zh'): string {
12
+ if (lang === 'en') {
13
+ return [
14
+ '## Engineering Standard (work like a senior engineer)',
15
+ '- Understand before changing: read the target code and its tests/callers (code_search → read_file) before editing. Match the surrounding style, naming, and existing patterns.',
16
+ '- Reuse first: prefer utilities/libraries already in the project over inventing new ones; check how similar things are done here.',
17
+ '- Root cause, not symptom: reproduce the failure, find why it happens, fix the cause — never paper over it or hardcode around a test.',
18
+ '- Minimal, surgical diffs: change only what the task needs. No drive-by reformatting or unrelated edits.',
19
+ '- Verify your work: after editing code, run get_diagnostics on the changed files and run the project tests/build (run_bash). Do not claim done until it is green; report the real result.',
20
+ '- Be honest about uncertainty: never fabricate APIs, file paths, or results. If unsure, say so and check.',
21
+ '- Security & performance: validate inputs, handle errors for real, avoid obvious injection / DoS / performance traps.',
22
+ 'You may read and modify Skyloom\'s own source.',
23
+ ].join('\n');
24
+ }
25
+ return [
26
+ '## 工程标准(像资深工程师一样工作)',
27
+ '- 改之前先理解:动手前先读目标代码及其测试/调用方(code_search → read_file),沿用周围的风格、命名与既有模式。',
28
+ '- 优先复用:优先用项目里已有的工具/库,而不是另造轮子;先看相似功能此处怎么做。',
29
+ '- 治根因不治症状:先复现失败,定位根本原因再修;绝不糊弄,绝不为了通过测试而硬编码。',
30
+ '- 最小手术式改动:只改任务所需,不顺手重排格式、不夹带无关修改。',
31
+ '- 改完必验证:改代码后对改动文件跑 get_diagnostics,并跑项目测试/构建(run_bash);未变绿不算完成,如实汇报真实结果。',
32
+ '- 诚实面对不确定:绝不编造 API、文件路径或结果;不确定就说明并去核实。',
33
+ '- 安全与性能:校验输入、做真实的错误处理,避开明显的注入/DoS/性能陷阱。',
34
+ '你可以阅读和修改 Skyloom 自身源码。',
35
+ ].join('\n');
36
+ }
@@ -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();