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.
Files changed (91) 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 +13 -0
  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/sandbox.d.ts +1 -0
  35. package/dist/core/sandbox.d.ts.map +1 -1
  36. package/dist/core/sandbox.js +1 -0
  37. package/dist/core/sandbox.js.map +1 -1
  38. package/dist/core/security.d.ts +22 -2
  39. package/dist/core/security.d.ts.map +1 -1
  40. package/dist/core/security.js +54 -24
  41. package/dist/core/security.js.map +1 -1
  42. package/dist/core/skill.d.ts +4 -0
  43. package/dist/core/skill.d.ts.map +1 -1
  44. package/dist/core/skill.js +1 -0
  45. package/dist/core/skill.js.map +1 -1
  46. package/dist/core/subagent.d.ts +75 -0
  47. package/dist/core/subagent.d.ts.map +1 -0
  48. package/dist/core/subagent.js +287 -0
  49. package/dist/core/subagent.js.map +1 -0
  50. package/dist/core/tool.d.ts +15 -1
  51. package/dist/core/tool.d.ts.map +1 -1
  52. package/dist/core/tool.js +88 -30
  53. package/dist/core/tool.js.map +1 -1
  54. package/dist/plugins/loader.d.ts +49 -8
  55. package/dist/plugins/loader.d.ts.map +1 -1
  56. package/dist/plugins/loader.js +129 -16
  57. package/dist/plugins/loader.js.map +1 -1
  58. package/dist/tools/builtin.d.ts.map +1 -1
  59. package/dist/tools/builtin.js +118 -13
  60. package/dist/tools/builtin.js.map +1 -1
  61. package/dist/tools/spawn.d.ts +23 -0
  62. package/dist/tools/spawn.d.ts.map +1 -0
  63. package/dist/tools/spawn.js +77 -0
  64. package/dist/tools/spawn.js.map +1 -0
  65. package/docs/OPTIMIZATION_PLAN.md +21 -4
  66. package/package.json +1 -1
  67. package/src/cli/loom_chat.ts +11 -0
  68. package/src/cli/main.ts +31 -1
  69. package/src/core/agent.ts +13 -0
  70. package/src/core/bgproc.ts +153 -0
  71. package/src/core/commands.ts +20 -0
  72. package/src/core/diagnostics.ts +178 -0
  73. package/src/core/diff.ts +98 -0
  74. package/src/core/envcontext.ts +79 -0
  75. package/src/core/factory.ts +31 -2
  76. package/src/core/sandbox.ts +1 -1
  77. package/src/core/security.ts +62 -21
  78. package/src/core/skill.ts +1 -1
  79. package/src/core/subagent.ts +272 -0
  80. package/src/core/tool.ts +86 -31
  81. package/src/plugins/loader.ts +145 -18
  82. package/src/tools/builtin.ts +107 -13
  83. package/src/tools/spawn.ts +92 -0
  84. package/tests/bgproc.test.ts +65 -0
  85. package/tests/diagnostics.test.ts +86 -0
  86. package/tests/edit_diff.test.ts +102 -0
  87. package/tests/envcontext.test.ts +67 -0
  88. package/tests/plugins.test.ts +84 -0
  89. package/tests/security.test.ts +87 -0
  90. package/tests/subagent.test.ts +211 -0
  91. 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
- - [ ] **P4.3 上下文快照**:环境信息、日期、工作区、激活技能合成一份"系统上下文",模型可见但与历史分离(轻量版 Context Epoch)。
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
- - [ ] **P5.1 工具 IO 校验**:工具定义带 input/output schema,非法输入不执行、非法输出不算成功(对标 opencode `tools.md`)。
128
- - [ ] **P5.2 权限内聚**:危险操作的 `permission.assert` 由工具自身发起,security 模块只评估策略 + 管理审批,统一 `ask/allow/deny` 语义。
129
- - [ ] **P5.3 插件 hook**:目录加载器升级为有序 hook(`init` / `tool.register` / `provider.update`),插件在自己的 scope 注册,卸载即移除。
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skyloom",
3
- "version": "1.16.2",
3
+ "version": "1.17.0",
4
4
  "description": "天空织机 Skyloom — 6 weather-themed AI agents: Fog, Rain, Frost, Snow, Dew, Fair",
5
5
  "preferGlobal": true,
6
6
  "type": "commonjs",
@@ -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
+ }
@@ -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
+ }
@@ -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
+ }