skyloom 1.16.1 → 1.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) 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 +25 -4
  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 +25 -1
  51. package/dist/core/tool.d.ts.map +1 -1
  52. package/dist/core/tool.js +113 -36
  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 +126 -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 +25 -4
  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 +119 -40
  81. package/src/plugins/loader.ts +145 -18
  82. package/src/tools/builtin.ts +115 -13
  83. package/src/tools/spawn.ts +92 -0
  84. package/tests/agent.test.ts +35 -2
  85. package/tests/bgproc.test.ts +65 -0
  86. package/tests/diagnostics.test.ts +86 -0
  87. package/tests/edit_diff.test.ts +102 -0
  88. package/tests/envcontext.test.ts +67 -0
  89. package/tests/plugins.test.ts +84 -0
  90. package/tests/security.test.ts +87 -0
  91. package/tests/subagent.test.ts +211 -0
  92. package/tests/tool.test.ts +116 -0
@@ -0,0 +1,23 @@
1
+ /**
2
+ * spawn_agent tool — launch a general-purpose, isolated-context subagent to
3
+ * handle one focused, self-contained task and return only its final report.
4
+ *
5
+ * This is sky's analogue of Claude Code's Task tool. Subagent types are
6
+ * discovered from built-ins plus `.claude/agents` / `.sky/agents` definition
7
+ * files (see core/subagent). Subagents never receive spawn_agent themselves,
8
+ * so there is no recursive fan-out.
9
+ */
10
+ import type { ToolDefinition } from '../core/tool';
11
+ import type { ToolRegistry } from '../core/tool';
12
+ import type { SkillRegistry } from '../core/skill';
13
+ import type { LLMClient } from '../core/llm';
14
+ import type { MessageBus } from '../core/bus';
15
+ export declare function createSpawnAgentTool(opts: {
16
+ config: any;
17
+ llm: LLMClient;
18
+ bus: MessageBus;
19
+ baseToolRegistry: ToolRegistry;
20
+ baseSkillRegistry: SkillRegistry;
21
+ cwd?: string;
22
+ }): ToolDefinition;
23
+ //# sourceMappingURL=spawn.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"spawn.d.ts","sourceRoot":"","sources":["../../src/tools/spawn.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AACnD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AACjD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AACnD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAC7C,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAG9C,wBAAgB,oBAAoB,CAAC,IAAI,EAAE;IACzC,MAAM,EAAE,GAAG,CAAC;IACZ,GAAG,EAAE,SAAS,CAAC;IACf,GAAG,EAAE,UAAU,CAAC;IAChB,gBAAgB,EAAE,YAAY,CAAC;IAC/B,iBAAiB,EAAE,aAAa,CAAC;IACjC,GAAG,CAAC,EAAE,MAAM,CAAC;CACd,GAAG,cAAc,CAmEjB"}
@@ -0,0 +1,77 @@
1
+ "use strict";
2
+ /**
3
+ * spawn_agent tool — launch a general-purpose, isolated-context subagent to
4
+ * handle one focused, self-contained task and return only its final report.
5
+ *
6
+ * This is sky's analogue of Claude Code's Task tool. Subagent types are
7
+ * discovered from built-ins plus `.claude/agents` / `.sky/agents` definition
8
+ * files (see core/subagent). Subagents never receive spawn_agent themselves,
9
+ * so there is no recursive fan-out.
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.createSpawnAgentTool = createSpawnAgentTool;
13
+ const subagent_1 = require("../core/subagent");
14
+ function createSpawnAgentTool(opts) {
15
+ const cwd = opts.cwd || process.cwd();
16
+ const buildDescription = () => {
17
+ const defs = (0, subagent_1.loadSubagentDefinitions)(cwd);
18
+ const lines = [...defs.values()].map((d) => ` - ${d.name}: ${d.description}`);
19
+ return ('派生一个隔离上下文的子智能体来独立完成一个聚焦、自洽的任务,只返回它的最终报告。' +
20
+ '当任务需要大量搜索/调研、或你想把一段独立工作从主上下文里隔离出去时使用。' +
21
+ '子智能体看不到你的对话历史,所以 task 必须自带全部所需上下文(目标、相关文件、约束)。' +
22
+ '它无法反问你,会一次性完成并汇报。可并行派生多个互不依赖的子智能体。\n\n可用子智能体类型:\n' +
23
+ lines.join('\n'));
24
+ };
25
+ return {
26
+ name: 'spawn_agent',
27
+ description: buildDescription(),
28
+ parameters: [
29
+ {
30
+ name: 'agent_type',
31
+ type: 'string',
32
+ description: '子智能体类型(见工具描述中的可用类型,如 general-purpose / explore 或自定义)',
33
+ required: true,
34
+ },
35
+ {
36
+ name: 'task',
37
+ type: 'string',
38
+ description: '完整、自洽的任务描述。子智能体看不到对话历史,必须在此写清目标、相关文件路径与约束。',
39
+ required: true,
40
+ },
41
+ {
42
+ name: 'description',
43
+ type: 'string',
44
+ description: '可选:对该任务的简短(3-5 词)标签,用于展示。',
45
+ required: false,
46
+ },
47
+ ],
48
+ // Long-running by nature (full nested agent loop); give it generous headroom.
49
+ timeout: 600000,
50
+ handler: async (params) => {
51
+ const agentType = String(params.agent_type || '').trim();
52
+ const task = String(params.task || '').trim();
53
+ if (!agentType)
54
+ return '[spawn_agent error] agent_type is required.';
55
+ if (!task)
56
+ return '[spawn_agent error] task is required.';
57
+ const defs = (0, subagent_1.loadSubagentDefinitions)(cwd);
58
+ const def = defs.get(agentType);
59
+ if (!def) {
60
+ const available = [...defs.keys()].join(', ');
61
+ return `[spawn_agent error] unknown agent_type '${agentType}'. Available: ${available}`;
62
+ }
63
+ const report = await (0, subagent_1.runSubagent)({
64
+ def,
65
+ task,
66
+ config: opts.config,
67
+ llm: opts.llm,
68
+ bus: opts.bus,
69
+ baseToolRegistry: opts.baseToolRegistry,
70
+ baseSkillRegistry: opts.baseSkillRegistry,
71
+ });
72
+ const header = `[subagent ${def.name} 完成]`;
73
+ return `${header}\n${report}`;
74
+ },
75
+ };
76
+ }
77
+ //# sourceMappingURL=spawn.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"spawn.js","sourceRoot":"","sources":["../../src/tools/spawn.ts"],"names":[],"mappings":";AAAA;;;;;;;;GAQG;;AASH,oDA0EC;AA5ED,+CAAwE;AAExE,SAAgB,oBAAoB,CAAC,IAOpC;IACC,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,IAAI,OAAO,CAAC,GAAG,EAAE,CAAC;IAEtC,MAAM,gBAAgB,GAAG,GAAW,EAAE;QACpC,MAAM,IAAI,GAAG,IAAA,kCAAuB,EAAC,GAAG,CAAC,CAAC;QAC1C,MAAM,KAAK,GAAG,CAAC,GAAG,IAAI,CAAC,MAAM,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,OAAO,CAAC,CAAC,IAAI,KAAK,CAAC,CAAC,WAAW,EAAE,CAAC,CAAC;QAC/E,OAAO,CACL,0CAA0C;YAC1C,uCAAuC;YACvC,gDAAgD;YAChD,mDAAmD;YACnD,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CACjB,CAAC;IACJ,CAAC,CAAC;IAEF,OAAO;QACL,IAAI,EAAE,aAAa;QACnB,WAAW,EAAE,gBAAgB,EAAE;QAC/B,UAAU,EAAE;YACV;gBACE,IAAI,EAAE,YAAY;gBAClB,IAAI,EAAE,QAAQ;gBACd,WAAW,EAAE,sDAAsD;gBACnE,QAAQ,EAAE,IAAI;aACf;YACD;gBACE,IAAI,EAAE,MAAM;gBACZ,IAAI,EAAE,QAAQ;gBACd,WAAW,EAAE,4CAA4C;gBACzD,QAAQ,EAAE,IAAI;aACf;YACD;gBACE,IAAI,EAAE,aAAa;gBACnB,IAAI,EAAE,QAAQ;gBACd,WAAW,EAAE,2BAA2B;gBACxC,QAAQ,EAAE,KAAK;aAChB;SACF;QACD,8EAA8E;QAC9E,OAAO,EAAE,MAAM;QACf,OAAO,EAAE,KAAK,EAAE,MAAM,EAAE,EAAE;YACxB,MAAM,SAAS,GAAG,MAAM,CAAC,MAAM,CAAC,UAAU,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC;YACzD,MAAM,IAAI,GAAG,MAAM,CAAC,MAAM,CAAC,IAAI,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC;YAC9C,IAAI,CAAC,SAAS;gBAAE,OAAO,6CAA6C,CAAC;YACrE,IAAI,CAAC,IAAI;gBAAE,OAAO,uCAAuC,CAAC;YAE1D,MAAM,IAAI,GAAG,IAAA,kCAAuB,EAAC,GAAG,CAAC,CAAC;YAC1C,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC;YAChC,IAAI,CAAC,GAAG,EAAE,CAAC;gBACT,MAAM,SAAS,GAAG,CAAC,GAAG,IAAI,CAAC,IAAI,EAAE,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;gBAC9C,OAAO,2CAA2C,SAAS,iBAAiB,SAAS,EAAE,CAAC;YAC1F,CAAC;YAED,MAAM,MAAM,GAAG,MAAM,IAAA,sBAAW,EAAC;gBAC/B,GAAG;gBACH,IAAI;gBACJ,MAAM,EAAE,IAAI,CAAC,MAAM;gBACnB,GAAG,EAAE,IAAI,CAAC,GAAG;gBACb,GAAG,EAAE,IAAI,CAAC,GAAG;gBACb,gBAAgB,EAAE,IAAI,CAAC,gBAAgB;gBACvC,iBAAiB,EAAE,IAAI,CAAC,iBAAiB;aAC1C,CAAC,CAAC;YAEH,MAAM,MAAM,GAAG,aAAa,GAAG,CAAC,IAAI,MAAM,CAAC;YAC3C,OAAO,GAAG,MAAM,KAAK,MAAM,EAAE,CAAC;QAChC,CAAC;KACF,CAAC;AACJ,CAAC"}
@@ -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.1",
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);
@@ -609,8 +622,12 @@ export class BaseAgent {
609
622
 
610
623
  for (let i = 0; i < parsed.length; i++) {
611
624
  const p = parsed[i];
612
- // Dedup: only for cacheable, non-dangerous tools with identical args
613
- if (options?.dedupCacheable && p.toolArgs && p.tool && (p.tool as ToolDefinition).cacheable && !(p.tool as ToolDefinition).dangerous) {
625
+ // Dedup identical calls within this round for read-only tools (idempotent
626
+ // or cacheable, never dangerous): the model often emits the same
627
+ // read_file / web_search twice in one parallel batch — run it once and
628
+ // share the result. Safe because one round observes one world state.
629
+ const td = p.tool as ToolDefinition | undefined;
630
+ if (options?.dedupCacheable && p.toolArgs && td && (td.idempotent || td.cacheable) && !td.dangerous) {
614
631
  const key = `${p.toolName}:${JSON.stringify(p.toolArgs, Object.keys(p.toolArgs).sort())}`;
615
632
  if (seenDedupKeys.has(key)) {
616
633
  execPlan.push({ idx: i, prep: p, isDuplicate: true });
@@ -629,6 +646,11 @@ export class BaseAgent {
629
646
  const results = new Array<{ tc: ToolCall; result: string; success: boolean; toolName: string } | null>(parsed.length).fill(null);
630
647
  const uniquePlan = execPlan.filter(e => !e.isDuplicate);
631
648
  const concurrency = resolveConcurrency((this.config as any)?.llm?.tool_concurrency);
649
+ // Enter ACTING once for the whole batch rather than per-tool (each parallel
650
+ // worker re-setting the same state was a redundant async no-op).
651
+ if (uniquePlan.some(e => e.prep.tool && !e.prep.parseError && !e.prep.denied)) {
652
+ await this.setState(AgentState.ACTING);
653
+ }
632
654
  const completed = await mapBounded(
633
655
  uniquePlan,
634
656
  async ({ idx, prep }, _i, aborted) => {
@@ -651,7 +673,6 @@ export class BaseAgent {
651
673
  }
652
674
 
653
675
  if (onStatus) onStatus(p.label);
654
- await this.setState(AgentState.ACTING);
655
676
 
656
677
  // File checkpoint: snapshot the target before any mutating file tool
657
678
  // runs, so /rewind can restore the pre-turn state.
@@ -1393,7 +1414,7 @@ export class BaseAgent {
1393
1414
  });
1394
1415
 
1395
1416
  // ── Execute all tools via shared pipeline ──
1396
- await this.executeToolCalls(response.toolCalls, { onStatus: onStatus ?? undefined, ephemeral });
1417
+ await this.executeToolCalls(response.toolCalls, { dedupCacheable: true, onStatus: onStatus ?? undefined, ephemeral });
1397
1418
  await this.setState(AgentState.THINKING);
1398
1419
  }
1399
1420
 
@@ -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
  {