skyloom 1.16.2 → 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 +13 -0
- 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 +15 -1
- package/dist/core/tool.d.ts.map +1 -1
- package/dist/core/tool.js +88 -30
- 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 +118 -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 +13 -0
- 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 +86 -31
- package/src/plugins/loader.ts +145 -18
- package/src/tools/builtin.ts +107 -13
- package/src/tools/spawn.ts +92 -0
- 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 +76 -0
|
@@ -120,13 +120,13 @@
|
|
|
120
120
|
|
|
121
121
|
- [x] **P4.1 Session 恢复 + 持久化修复**:新增 CLI `/sessions`(编号列表、标记当前)、`/resume <序号|id>`、`/new`。**并修复了一个严重 bug**:`persistDb()`(唯一写盘的代码)**从未被调用** → sql.js 纯内存 → 会话/长期记忆/工作记忆**重启全丢**("启动自动恢复最新会话"形同虚设)。改为所有写经 `dbRun` 触发**防抖落盘** + `close()` 同步保存。新增跨实例持久化回归测试。
|
|
122
122
|
- [x] **P4.2 自动压缩(catalog 感知触发)**:`shouldAutoCompact()`/`contextUsage()` 不再硬编码 128K —— 改为按当前模型在 catalog 里的真实 `context` 窗口判断(留 20% 余量给回复)。修复了小窗口模型(deepseek-reasoner 64K、mixtral 32K)长聊时**先于压缩就溢出**的隐患;状态栏 % 与模型名也正确了。压缩本身(摘要 + 保留近 N 条 + 指令保真)已存在并已接线。后续:结构化 checkpoint + 溢出后重试(对标 opencode)。
|
|
123
|
-
- [
|
|
123
|
+
- [x] **P4.3 环境上下文快照**(对标 Claude Code `<env>` 块):[`envcontext.ts`](../src/core/envcontext.ts) 的 `buildEnvBlock` 合成「运行环境」块(工作目录、平台、Node、git 仓库/分支、日期),`gitInfo` 用纯文件读取识别仓库与分支(兼容 worktree 的 `.git` 文件)。`BaseAgent.injectEnvironment` 在 init/reinitLanguage 把它注入系统提示,与对话历史分离。新增 [`tests/envcontext.test.ts`](../tests/envcontext.test.ts) 6 用例。
|
|
124
124
|
|
|
125
125
|
### Phase 5 — 工具与插件健壮性|~1 天
|
|
126
126
|
|
|
127
|
-
- [
|
|
128
|
-
- [
|
|
129
|
-
- [
|
|
127
|
+
- [x] **P5.1 工具输入校验 + 强制 coercion**(对标 opencode `tools.md`):`ToolRegistry.validateAndCoerce` 在 `execute` 里于缓存/执行**之前**校验并归一化入参 —— 必填项缺失即拒、按声明类型 coerce(`"5"`→5、`"true"`→true、JSON 串→array/object)、enum 成员校验,handler 收到的是干净的类型化值,非法输入返回**可操作的**错误供模型重试。修了 `parseInt` 截断浮点(`"3.5"`→3)的隐患。见 [`tests/tool.test.ts`](../tests/tool.test.ts)(+7 用例)。输出校验暂未做。
|
|
128
|
+
- [x] **P5.2 权限模式内聚**(对标 Claude Code 权限模式):把审批逻辑收敛为一个纯函数 `decideApproval(level, mode, tool) → allow/ask/deny`([security.ts](../src/core/security.ts)),`checkApproval` 只做红线门禁 + 调用它 + 回调。新增模式 `acceptEdits`(自动放行文件编辑类工具,其余照 default 询问)与 `bypass`(除红线外全放行);保留 `auto/interactive/strict` 行为不变。`/perm <default|auto|accept|strict|bypass>` 运行时切换(linear + loom),`config.cli.approvalMode` 启动时生效。新增 [`tests/security.test.ts`](../tests/security.test.ts) 11 用例覆盖决策矩阵。后续:让工具自身发起 `permission.assert`(细粒度 scope)。
|
|
129
|
+
- [x] **P5.3 插件 hook 生命周期**(对标 opencode 插件):[`plugins/loader.ts`](../src/plugins/loader.ts) 从扁平加载器升级为有序 hook 系统。插件导出 `activate(ctx)`,通过 `ctx.registerTool` / `ctx.on(hook, fn)` 在**自己的 scope** 注册;`unload(name)` 精确移除其工具与 hook handler(并调 `deactivate`)。核心 hook:`init`(加载后、agent 起来前由 `SystemContext.initAll` 触发)、`tool.register`、`provider.update`。handler 按注册顺序触发、单个抛错隔离。**保留 legacy `register(registry)`**(diff 注册表追踪其工具以支持卸载)。新增 [`tests/plugins.test.ts`](../tests/plugins.test.ts) 7 用例。后续:`provider.update` 在 model_config 变更处触发。
|
|
130
130
|
|
|
131
131
|
### Phase 6 — 测试与质量门禁|~1.5 天
|
|
132
132
|
|
|
@@ -176,3 +176,20 @@ M5 打磨成品 Phase 7 → 美学工程化 + 品牌资产 + 发布
|
|
|
176
176
|
| 流式与工具执行交错复杂 | 复用已存在的 `chatStreamImpl` 事件模型,只接线不重写 |
|
|
177
177
|
| Catalog 迁移漏模型 | 以 `config/models.yaml` 为真值源迁移,向导/README 派生 |
|
|
178
178
|
| 美学改动破坏现有观感 | 设计 token 抽取为"提取"非"重画",先快照对比 |
|
|
179
|
+
|
|
180
|
+
---
|
|
181
|
+
|
|
182
|
+
## 6. Phase 8 — Claude Code / opencode 能力对齐 ✅ 已完成
|
|
183
|
+
|
|
184
|
+
四项核心能力补齐,全部 `tsc --noEmit` 绿 + 单测覆盖(351 → 387 用例,+36):
|
|
185
|
+
|
|
186
|
+
- [x] **P8.1 通用可定义子智能体**(对标 Claude Code `Task` 工具)
|
|
187
|
+
[`src/core/subagent.ts`](../src/core/subagent.ts) + [`src/tools/spawn.ts`](../src/tools/spawn.ts):`spawn_agent` 工具派生**隔离上下文**的子智能体(独立临时记忆,用完即删),只回传最终报告。内置 `general-purpose` / `explore`;支持 `.sky/agents/*.md` 与 `.claude/agents/*.md` 自定义(frontmatter 兼容 Claude Code,工具名自动映射)。子智能体永不持有 `spawn_agent`(无递归)。`/agents` 列举。新增 [`tests/subagent.test.ts`](../tests/subagent.test.ts) 13 用例。
|
|
188
|
+
- [x] **P8.2 精确编辑 + diff**(对标 Claude Code `Edit`)
|
|
189
|
+
[`src/core/diff.ts`](../src/core/diff.ts) + 升级 `edit_file`:强制**唯一匹配**(歧义即拒)、`replace_all`、no-op 检测、返回统一 diff(+/- 统计)。并修复旧实现 `String.replace` 把 `$&`/`$1` 当替换模式的隐患。新增 [`tests/edit_diff.test.ts`](../tests/edit_diff.test.ts) 10 用例。
|
|
190
|
+
- [x] **P8.3 后台任务 / 长进程**(对标 Claude Code Bash `run_in_background`)
|
|
191
|
+
[`src/core/bgproc.ts`](../src/core/bgproc.ts):`run_bash` 加 `background=true` 派后台子进程,`bash_output`(增量读)/`list_bash`/`kill_bash` 控制。复用沙箱红线预检;滚动输出上限;会话退出 `killAll`(无孤儿)。新增 [`tests/bgproc.test.ts`](../tests/bgproc.test.ts) 5 用例。
|
|
192
|
+
- [x] **P8.4 诊断(LSP 关键能力)**(对标 opencode LSP)
|
|
193
|
+
[`src/core/diagnostics.ts`](../src/core/diagnostics.ts) + `get_diagnostics` 工具:TS/JS 经**工作区** TypeScript 编译器 API 取真实语义诊断(行:列 + TS 码),零额外安装;其他语言走 `config.diagnostics` 配置的外部 checker(解析 `file:line:col` 输出)。新增 [`tests/diagnostics.test.ts`](../tests/diagnostics.test.ts) 8 用例。
|
|
194
|
+
|
|
195
|
+
> 这是把"完整的多 Agent 终端"推进到**与 Claude Code / opencode 同代能力面**的一步:可派生子智能体、精确可审计的编辑、后台进程、按文件诊断闭环。
|
package/package.json
CHANGED
package/src/cli/loom_chat.ts
CHANGED
|
@@ -424,6 +424,17 @@ export async function loomChat(ctx: any, startAgent: any, deps: LoomChatDeps): P
|
|
|
424
424
|
dim(`模式 → ${mode.current}${mode.current === "default" ? "" : " · Shift+Tab 或 /default 切回"}`);
|
|
425
425
|
continue;
|
|
426
426
|
}
|
|
427
|
+
if (cmdL === "/perm" || cmdL.startsWith("/perm ")) {
|
|
428
|
+
const { getSecurity, PERMISSION_MODE_ALIASES } = require("../core/security");
|
|
429
|
+
const sec = getSecurity();
|
|
430
|
+
const arg = inp.split(/\s+/)[1]?.toLowerCase();
|
|
431
|
+
if (!arg) { dim(`权限模式: ${sec.approvalMode} · 可选 default | auto | accept | strict | bypass`); continue; }
|
|
432
|
+
const m = PERMISSION_MODE_ALIASES[arg];
|
|
433
|
+
if (!m) { dim(`未知权限模式 '${arg}' · 可选 default | auto | accept | strict | bypass`); continue; }
|
|
434
|
+
sec.setMode(m);
|
|
435
|
+
dim(`✓ 权限模式 → ${m}`);
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
427
438
|
if (cmdL === "/context") {
|
|
428
439
|
try {
|
|
429
440
|
const d = agent.contextDetail();
|
package/src/cli/main.ts
CHANGED
|
@@ -303,8 +303,14 @@ async function chat(agentName: string, modelOverride?: string, classic?: boolean
|
|
|
303
303
|
|
|
304
304
|
// Wire up security approval — prompt user for HIGH/CRITICAL operations
|
|
305
305
|
try {
|
|
306
|
-
const { getSecurity, DangerLevel } = require("../core/security");
|
|
306
|
+
const { getSecurity, DangerLevel, PERMISSION_MODE_ALIASES } = require("../core/security");
|
|
307
307
|
const sec = getSecurity();
|
|
308
|
+
// Honor a configured permission mode (config.yaml cli.approvalMode), mapped
|
|
309
|
+
// through the same aliases as /perm.
|
|
310
|
+
const cfgMode = (ctx as any).config?.cli?.approvalMode || (ctx as any).config?.cli?.approval_mode;
|
|
311
|
+
if (cfgMode && PERMISSION_MODE_ALIASES[String(cfgMode).toLowerCase()]) {
|
|
312
|
+
sec.setMode(PERMISSION_MODE_ALIASES[String(cfgMode).toLowerCase()]);
|
|
313
|
+
}
|
|
308
314
|
sec.setApprovalCallback(async (tool: string, args: Record<string, any>, level: number) => {
|
|
309
315
|
process.stdout.write(chalk.yellow(`\n ⚠ ${tool} ( danger level ${level} )\n`));
|
|
310
316
|
process.stdout.write(chalk.dim(` args: ${JSON.stringify(args).slice(0, 80)}\n`));
|
|
@@ -415,6 +421,17 @@ async function chat(agentName: string, modelOverride?: string, classic?: boolean
|
|
|
415
421
|
process.stdout.write(chalk.dim(` 模式 → ${MODE.current} · ${MODE.describe()}\n`));
|
|
416
422
|
continue;
|
|
417
423
|
}
|
|
424
|
+
if (cmdL === "/perm" || cmdL.startsWith("/perm ")) {
|
|
425
|
+
const { getSecurity, PERMISSION_MODE_ALIASES } = require("../core/security");
|
|
426
|
+
const sec = getSecurity();
|
|
427
|
+
const arg = inp.split(/\s+/)[1]?.toLowerCase();
|
|
428
|
+
if (!arg) { process.stdout.write(chalk.dim(` 权限模式: ${sec.approvalMode} · 可选 default | auto | accept | strict | bypass\n`)); continue; }
|
|
429
|
+
const m = PERMISSION_MODE_ALIASES[arg];
|
|
430
|
+
if (!m) { process.stdout.write(chalk.yellow(` 未知权限模式 '${arg}' · 可选 default | auto | accept | strict | bypass\n`)); continue; }
|
|
431
|
+
sec.setMode(m);
|
|
432
|
+
process.stdout.write(chalk.green(` ✓ 权限模式 → ${m}\n`));
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
418
435
|
if (cmdL === "/context") {
|
|
419
436
|
try {
|
|
420
437
|
const d = currentAgent.contextDetail();
|
|
@@ -438,6 +455,19 @@ async function chat(agentName: string, modelOverride?: string, classic?: boolean
|
|
|
438
455
|
process.stdout.write("\n");
|
|
439
456
|
continue;
|
|
440
457
|
}
|
|
458
|
+
if (cmdL === "/agents") {
|
|
459
|
+
const { loadSubagentDefinitions } = require("../core/subagent");
|
|
460
|
+
const defs = loadSubagentDefinitions();
|
|
461
|
+
process.stdout.write(chalk.bold(`\n 可派生子智能体 · spawn_agent\n`));
|
|
462
|
+
for (const d of defs.values()) {
|
|
463
|
+
const scope = d.source === "builtin" ? "内置" : "自定义";
|
|
464
|
+
const tools = d.tools === null ? "全部工具" : `${d.tools.length} 个工具`;
|
|
465
|
+
process.stdout.write(chalk.dim(` ◇ ${String(d.name).padEnd(18)} ${d.description}\n`));
|
|
466
|
+
process.stdout.write(chalk.dim(` └ ${scope} · ${tools}${d.model ? ` · ${d.model}` : ""}\n`));
|
|
467
|
+
}
|
|
468
|
+
process.stdout.write(chalk.dim(`\n 自定义: 在 .sky/agents/ 或 .claude/agents/ 放 <name>.md (frontmatter: description/tools/model)\n\n`));
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
441
471
|
if (cmdL === "/trace") {
|
|
442
472
|
const trace = (currentAgent as any).getLastTrace?.();
|
|
443
473
|
if (!trace || !trace.spans?.length) { process.stdout.write(chalk.dim(" 本会话还没有可追踪的运行\n")); continue; }
|
package/src/core/agent.ts
CHANGED
|
@@ -166,6 +166,17 @@ export class BaseAgent {
|
|
|
166
166
|
}
|
|
167
167
|
}
|
|
168
168
|
|
|
169
|
+
/** Consolidated environment snapshot (cwd/platform/git/date) — see envcontext. */
|
|
170
|
+
protected injectEnvironment(prompt: string): string {
|
|
171
|
+
try {
|
|
172
|
+
const { buildEnvBlock } = require('./envcontext');
|
|
173
|
+
const lang = (this.config as any).llm?.language || 'zh';
|
|
174
|
+
return prompt + '\n\n' + buildEnvBlock({ lang });
|
|
175
|
+
} catch {
|
|
176
|
+
return prompt;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
169
180
|
/** Always return the live current time — never stale. */
|
|
170
181
|
protected currentTimeTag(): string {
|
|
171
182
|
const date = new Date();
|
|
@@ -229,6 +240,7 @@ export class BaseAgent {
|
|
|
229
240
|
this._baseSystemPrompt = '';
|
|
230
241
|
this._baseSystemPrompt = this.resolveSystemPrompt();
|
|
231
242
|
this._baseSystemPrompt = this.injectWorkspaceInfo(this._baseSystemPrompt);
|
|
243
|
+
this._baseSystemPrompt = this.injectEnvironment(this._baseSystemPrompt);
|
|
232
244
|
this._baseSystemPrompt = this.injectBehaviorRules(this._baseSystemPrompt);
|
|
233
245
|
this._baseSystemPrompt = this.injectProgrammingWisdom(this._baseSystemPrompt);
|
|
234
246
|
this._baseSystemPrompt = this.injectProjectMemory(this._baseSystemPrompt);
|
|
@@ -254,6 +266,7 @@ export class BaseAgent {
|
|
|
254
266
|
|
|
255
267
|
this._baseSystemPrompt = this.resolveSystemPrompt();
|
|
256
268
|
this._baseSystemPrompt = this.injectWorkspaceInfo(this._baseSystemPrompt);
|
|
269
|
+
this._baseSystemPrompt = this.injectEnvironment(this._baseSystemPrompt);
|
|
257
270
|
this._baseSystemPrompt = this.injectBehaviorRules(this._baseSystemPrompt);
|
|
258
271
|
this._baseSystemPrompt = this.injectProgrammingWisdom(this._baseSystemPrompt);
|
|
259
272
|
this._baseSystemPrompt = this.injectProjectMemory(this._baseSystemPrompt);
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Background process manager — long-running shells that don't block the agent.
|
|
3
|
+
*
|
|
4
|
+
* `run_bash` with background=true spawns a child here and returns immediately
|
|
5
|
+
* with a job id. The agent later pulls incremental output (bash_output), lists
|
|
6
|
+
* jobs (list_bash), or terminates one (kill_bash). Children are NOT detached,
|
|
7
|
+
* so they die with the sky process — no orphan management needed across runs.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { spawn, type ChildProcess, execSync } from 'child_process';
|
|
11
|
+
import { getLogger } from './logger';
|
|
12
|
+
import { preflightCheck } from './sandbox';
|
|
13
|
+
|
|
14
|
+
const log = getLogger('bgproc');
|
|
15
|
+
|
|
16
|
+
/** Per-job rolling output cap — keep the tail, drop the oldest. */
|
|
17
|
+
const MAX_LOG_BYTES = 512 * 1024;
|
|
18
|
+
|
|
19
|
+
export type BgStatus = 'running' | 'exited' | 'killed' | 'error';
|
|
20
|
+
|
|
21
|
+
export interface BgJobView {
|
|
22
|
+
id: string;
|
|
23
|
+
command: string;
|
|
24
|
+
pid: number | null;
|
|
25
|
+
status: BgStatus;
|
|
26
|
+
exitCode: number | null;
|
|
27
|
+
startedAt: number;
|
|
28
|
+
endedAt: number | null;
|
|
29
|
+
/** Total bytes ever produced (before any rolling trim). */
|
|
30
|
+
totalBytes: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface BgJob extends BgJobView {
|
|
34
|
+
child: ChildProcess | null;
|
|
35
|
+
log: string; // combined stdout+stderr in arrival order
|
|
36
|
+
readOffset: number; // cursor for incremental reads
|
|
37
|
+
trimmed: number; // bytes dropped from the front by the rolling cap
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
class BackgroundManager {
|
|
41
|
+
private jobs = new Map<string, BgJob>();
|
|
42
|
+
private seq = 0;
|
|
43
|
+
|
|
44
|
+
private append(job: BgJob, text: string): void {
|
|
45
|
+
job.log += text;
|
|
46
|
+
job.totalBytes += Buffer.byteLength(text, 'utf8');
|
|
47
|
+
if (job.log.length > MAX_LOG_BYTES) {
|
|
48
|
+
const drop = job.log.length - MAX_LOG_BYTES;
|
|
49
|
+
job.log = job.log.slice(drop);
|
|
50
|
+
job.trimmed += drop;
|
|
51
|
+
job.readOffset = Math.max(0, job.readOffset - drop);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Start a background command. Returns the job id, or an error string. */
|
|
56
|
+
start(command: string, opts?: { cwd?: string; env?: Record<string, string> }): { id?: string; error?: string } {
|
|
57
|
+
const check = preflightCheck(command);
|
|
58
|
+
if (check) return { error: `[BLOCKED] ${check}` };
|
|
59
|
+
|
|
60
|
+
const id = `bg_${(++this.seq).toString(36)}_${Date.now().toString(36)}`;
|
|
61
|
+
let child: ChildProcess;
|
|
62
|
+
try {
|
|
63
|
+
child = spawn(command, {
|
|
64
|
+
shell: true,
|
|
65
|
+
cwd: opts?.cwd || process.cwd(),
|
|
66
|
+
env: { ...process.env, ...(opts?.env || {}) },
|
|
67
|
+
windowsHide: true,
|
|
68
|
+
});
|
|
69
|
+
} catch (e: any) {
|
|
70
|
+
return { error: `Failed to start background command: ${e.message || e}` };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const job: BgJob = {
|
|
74
|
+
id, command, pid: child.pid ?? null, status: 'running', exitCode: null,
|
|
75
|
+
startedAt: Date.now(), endedAt: null, totalBytes: 0,
|
|
76
|
+
child, log: '', readOffset: 0, trimmed: 0,
|
|
77
|
+
};
|
|
78
|
+
this.jobs.set(id, job);
|
|
79
|
+
|
|
80
|
+
child.stdout?.on('data', (d) => this.append(job, d.toString()));
|
|
81
|
+
child.stderr?.on('data', (d) => this.append(job, d.toString()));
|
|
82
|
+
child.on('error', (e) => {
|
|
83
|
+
job.status = 'error';
|
|
84
|
+
job.endedAt = Date.now();
|
|
85
|
+
this.append(job, `\n[spawn error] ${e.message}\n`);
|
|
86
|
+
log.warn('bg_error', { id, error: e.message });
|
|
87
|
+
});
|
|
88
|
+
child.on('exit', (code, signal) => {
|
|
89
|
+
if (job.status === 'running') job.status = signal ? 'killed' : 'exited';
|
|
90
|
+
job.exitCode = code;
|
|
91
|
+
job.endedAt = Date.now();
|
|
92
|
+
job.child = null;
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
return { id };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
get(id: string): BgJob | undefined { return this.jobs.get(id); }
|
|
99
|
+
|
|
100
|
+
list(): BgJobView[] {
|
|
101
|
+
return [...this.jobs.values()].map(toView).sort((a, b) => b.startedAt - a.startedAt);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Read output produced since the last read; advances the cursor. */
|
|
105
|
+
read(id: string): { ok: boolean; text?: string; status?: BgStatus; exitCode?: number | null; error?: string } {
|
|
106
|
+
const job = this.jobs.get(id);
|
|
107
|
+
if (!job) return { ok: false, error: `No background job '${id}'.` };
|
|
108
|
+
const chunk = job.log.slice(job.readOffset);
|
|
109
|
+
job.readOffset = job.log.length;
|
|
110
|
+
return { ok: true, text: chunk, status: job.status, exitCode: job.exitCode };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
kill(id: string): { ok: boolean; error?: string } {
|
|
114
|
+
const job = this.jobs.get(id);
|
|
115
|
+
if (!job) return { ok: false, error: `No background job '${id}'.` };
|
|
116
|
+
if (job.status !== 'running' || !job.child) return { ok: false, error: `Job '${id}' is not running (${job.status}).` };
|
|
117
|
+
const pid = job.child.pid;
|
|
118
|
+
try {
|
|
119
|
+
if (process.platform === 'win32' && pid) {
|
|
120
|
+
// child.kill() doesn't reap the cmd.exe process tree on Windows.
|
|
121
|
+
try { execSync(`taskkill /pid ${pid} /T /F`, { stdio: 'ignore' }); }
|
|
122
|
+
catch { job.child.kill(); }
|
|
123
|
+
} else {
|
|
124
|
+
job.child.kill('SIGTERM');
|
|
125
|
+
}
|
|
126
|
+
job.status = 'killed';
|
|
127
|
+
job.endedAt = Date.now();
|
|
128
|
+
return { ok: true };
|
|
129
|
+
} catch (e: any) {
|
|
130
|
+
return { ok: false, error: `Failed to kill '${id}': ${e.message || e}` };
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Terminate all running jobs (called on session shutdown). */
|
|
135
|
+
killAll(): void {
|
|
136
|
+
for (const job of this.jobs.values()) {
|
|
137
|
+
if (job.status === 'running') this.kill(job.id);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function toView(j: BgJob): BgJobView {
|
|
143
|
+
return {
|
|
144
|
+
id: j.id, command: j.command, pid: j.pid, status: j.status,
|
|
145
|
+
exitCode: j.exitCode, startedAt: j.startedAt, endedAt: j.endedAt, totalBytes: j.totalBytes,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
let _mgr: BackgroundManager | null = null;
|
|
150
|
+
export function getBackgroundManager(): BackgroundManager {
|
|
151
|
+
if (!_mgr) _mgr = new BackgroundManager();
|
|
152
|
+
return _mgr;
|
|
153
|
+
}
|
package/src/core/commands.ts
CHANGED
|
@@ -313,6 +313,16 @@ export const BUILTIN_COMMANDS: CommandInfo[] = [
|
|
|
313
313
|
takesArgs: false,
|
|
314
314
|
source: 'builtin',
|
|
315
315
|
},
|
|
316
|
+
{
|
|
317
|
+
name: 'agents',
|
|
318
|
+
aliases: [],
|
|
319
|
+
description: 'List spawnable subagents',
|
|
320
|
+
label: '可派生子智能体',
|
|
321
|
+
category: 'context',
|
|
322
|
+
hints: [],
|
|
323
|
+
takesArgs: false,
|
|
324
|
+
source: 'builtin',
|
|
325
|
+
},
|
|
316
326
|
{
|
|
317
327
|
name: 'workspace',
|
|
318
328
|
aliases: [],
|
|
@@ -428,6 +438,16 @@ export const BUILTIN_COMMANDS: CommandInfo[] = [
|
|
|
428
438
|
takesArgs: false,
|
|
429
439
|
source: 'builtin',
|
|
430
440
|
},
|
|
441
|
+
{
|
|
442
|
+
name: 'perm',
|
|
443
|
+
aliases: [],
|
|
444
|
+
description: 'Set permission mode (default|auto|accept|strict|bypass)',
|
|
445
|
+
label: '权限模式(default/auto/accept/strict/bypass)',
|
|
446
|
+
category: 'workflow',
|
|
447
|
+
hints: ['default', 'auto', 'accept', 'strict', 'bypass'],
|
|
448
|
+
takesArgs: true,
|
|
449
|
+
source: 'builtin',
|
|
450
|
+
},
|
|
431
451
|
|
|
432
452
|
// ── File & Checkpoint ──
|
|
433
453
|
{
|
|
@@ -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
|
+
}
|