@kkelly-offical/kkcode 0.1.3 → 0.1.6

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 (58) hide show
  1. package/README.md +110 -172
  2. package/package.json +46 -46
  3. package/src/agent/agent.mjs +41 -0
  4. package/src/agent/prompt/frontend-designer.txt +58 -0
  5. package/src/agent/prompt/longagent-blueprint-agent.txt +83 -0
  6. package/src/agent/prompt/longagent-coding-agent.txt +37 -0
  7. package/src/agent/prompt/longagent-debugging-agent.txt +46 -0
  8. package/src/agent/prompt/longagent-preview-agent.txt +63 -0
  9. package/src/config/defaults.mjs +260 -195
  10. package/src/config/schema.mjs +71 -6
  11. package/src/core/constants.mjs +91 -46
  12. package/src/index.mjs +1 -1
  13. package/src/knowledge/frontend-aesthetics.txt +39 -0
  14. package/src/knowledge/loader.mjs +2 -1
  15. package/src/knowledge/tailwind.txt +12 -3
  16. package/src/mcp/client-http.mjs +141 -157
  17. package/src/mcp/client-sse.mjs +288 -286
  18. package/src/mcp/client-stdio.mjs +533 -451
  19. package/src/mcp/constants.mjs +2 -0
  20. package/src/mcp/registry.mjs +479 -394
  21. package/src/mcp/stdio-framing.mjs +133 -127
  22. package/src/mcp/tool-result.mjs +24 -0
  23. package/src/observability/index.mjs +42 -0
  24. package/src/observability/metrics.mjs +137 -0
  25. package/src/observability/tracer.mjs +137 -0
  26. package/src/orchestration/background-manager.mjs +372 -358
  27. package/src/orchestration/background-worker.mjs +305 -245
  28. package/src/orchestration/longagent-manager.mjs +171 -116
  29. package/src/orchestration/stage-scheduler.mjs +728 -489
  30. package/src/permission/exec-policy.mjs +9 -11
  31. package/src/provider/anthropic.mjs +1 -0
  32. package/src/provider/openai.mjs +340 -339
  33. package/src/provider/retry-policy.mjs +68 -68
  34. package/src/provider/router.mjs +241 -228
  35. package/src/provider/sse.mjs +104 -91
  36. package/src/repl.mjs +1 -1
  37. package/src/session/checkpoint.mjs +66 -3
  38. package/src/session/engine.mjs +227 -225
  39. package/src/session/longagent-4stage.mjs +460 -0
  40. package/src/session/longagent-hybrid.mjs +1081 -0
  41. package/src/session/longagent-plan.mjs +365 -329
  42. package/src/session/longagent-project-memory.mjs +53 -0
  43. package/src/session/longagent-scaffold.mjs +291 -100
  44. package/src/session/longagent-task-bus.mjs +54 -0
  45. package/src/session/longagent-utils.mjs +472 -0
  46. package/src/session/longagent.mjs +884 -1462
  47. package/src/session/project-context.mjs +30 -0
  48. package/src/session/store.mjs +510 -503
  49. package/src/session/task-validator.mjs +4 -3
  50. package/src/skill/builtin/design.mjs +76 -0
  51. package/src/skill/builtin/frontend.mjs +8 -0
  52. package/src/skill/registry.mjs +390 -336
  53. package/src/storage/ghost-commit-store.mjs +18 -8
  54. package/src/tool/executor.mjs +11 -0
  55. package/src/tool/git-auto.mjs +0 -19
  56. package/src/tool/registry.mjs +71 -37
  57. package/src/ui/activity-renderer.mjs +664 -410
  58. package/src/util/git.mjs +23 -0
@@ -6,6 +6,9 @@ const GHOST_COMMIT_DIR = "ghost-commits"
6
6
  const MAX_GHOST_COMMITS_PER_REPO = 50 // 每个仓库最多保留的幽灵提交数
7
7
  const GHOST_COMMIT_TTL_MS = 7 * 24 * 60 * 60 * 1000 // 7天过期
8
8
 
9
+ // 防止并发 cleanup 竞态:per-repo 锁
10
+ const cleanupLocks = new Map()
11
+
9
12
  /**
10
13
  * Ghost Commit 存储管理
11
14
  *
@@ -152,16 +155,23 @@ export async function deleteGhostCommit(repoPath, ghostCommitId) {
152
155
  * @param {string} repoPath - 仓库路径
153
156
  */
154
157
  export async function cleanupOldGhostCommits(repoPath) {
155
- const commits = await listGhostCommits(repoPath, { includeExpired: true })
158
+ // 同一 repo cleanup 串行化,防止并发竞态
159
+ if (cleanupLocks.get(repoPath)) return
160
+ cleanupLocks.set(repoPath, true)
161
+ try {
162
+ const commits = await listGhostCommits(repoPath, { includeExpired: true })
156
163
 
157
- if (commits.length <= MAX_GHOST_COMMITS_PER_REPO) {
158
- return
159
- }
164
+ if (commits.length <= MAX_GHOST_COMMITS_PER_REPO) {
165
+ return
166
+ }
160
167
 
161
- // 删除多余的旧提交
162
- const toDelete = commits.slice(MAX_GHOST_COMMITS_PER_REPO)
163
- for (const commit of toDelete) {
164
- await deleteGhostCommit(repoPath, commit.id)
168
+ // 删除多余的旧提交
169
+ const toDelete = commits.slice(MAX_GHOST_COMMITS_PER_REPO)
170
+ for (const commit of toDelete) {
171
+ await deleteGhostCommit(repoPath, commit.id)
172
+ }
173
+ } finally {
174
+ cleanupLocks.delete(repoPath)
165
175
  }
166
176
  }
167
177
 
@@ -2,6 +2,10 @@ import { makeToolResult } from "../core/types.mjs"
2
2
  import { EventBus } from "../core/events.mjs"
3
3
  import { EVENT_TYPES } from "../core/constants.mjs"
4
4
  import { withAudit } from "./audit-wrapper.mjs"
5
+ import { autoSnapshotBeforeEdit } from "../session/checkpoint.mjs"
6
+
7
+ const FILE_EDIT_TOOLS = new Set(["write", "edit", "multiedit", "patch", "notebookedit"])
8
+ const snapshotted = new Set()
5
9
 
6
10
  export async function executeTool({ tool, args, sessionId, turnId, context, signal = null }) {
7
11
  return withAudit({
@@ -44,6 +48,13 @@ export async function executeTool({ tool, args, sessionId, turnId, context, sign
44
48
  return cancelled
45
49
  }
46
50
 
51
+ // Auto snapshot before first file edit per turn
52
+ if (FILE_EDIT_TOOLS.has(tool.name) && !snapshotted.has(turnId)) {
53
+ snapshotted.add(turnId)
54
+ if (snapshotted.size > 200) snapshotted.clear()
55
+ autoSnapshotBeforeEdit(sessionId, context.cwd, context.config).catch(() => {})
56
+ }
57
+
47
58
  const raw = await tool.execute(args || {}, context)
48
59
  let output = ""
49
60
  let metadata = {}
@@ -524,22 +524,3 @@ export async function getLastSnapshotId(sessionId) {
524
524
  return state.lastSnapshotId
525
525
  }
526
526
 
527
- /**
528
- * 在修改前自动创建快照(如果配置启用)
529
- */
530
- export async function autoSnapshotBeforeEdit(sessionId, cwd, config = {}) {
531
- if (!config.git_auto?.enabled || !config.git_auto?.auto_snapshot) {
532
- return { skipped: true, reason: "auto_snapshot_disabled" }
533
- }
534
-
535
- if (!(await isGitRepo(cwd))) {
536
- return { skipped: true, reason: "not_a_git_repo" }
537
- }
538
-
539
- const result = await gitSnapshotTool.execute(
540
- { auto: true, message: "Auto snapshot before AI edit" },
541
- { cwd, sessionId }
542
- )
543
-
544
- return result
545
- }
@@ -1,7 +1,7 @@
1
1
  import path from "node:path"
2
2
  import { readdir, readFile } from "node:fs/promises"
3
3
  import { access, stat, unlink } from "node:fs/promises"
4
- import { exec as execCb } from "node:child_process"
4
+ import { exec as execCb, spawn } from "node:child_process"
5
5
  import { promisify } from "node:util"
6
6
  import { pathToFileURL } from "node:url"
7
7
  import { atomicWriteFile, replaceInFileTransactional, replaceAllInFileTransactional, diffLineCount } from "./edit-transaction.mjs"
@@ -23,7 +23,8 @@ const state = {
23
23
  loadedAt: 0,
24
24
  lastSignature: "",
25
25
  lastCwd: "",
26
- lastConfig: null
26
+ lastConfig: null,
27
+ refreshing: false
27
28
  }
28
29
 
29
30
  function schema(type, description) {
@@ -70,16 +71,37 @@ function wasFileRead(filePath) {
70
71
  return fileReadTracker.has(filePath)
71
72
  }
72
73
 
74
+ function runRg(args, cwd, timeoutMs = 30000) {
75
+ return new Promise((resolve) => {
76
+ let stdout = "", stderr = "", done = false
77
+ const child = spawn("rg", ["--no-config", ...args], {
78
+ cwd, windowsHide: true, stdio: ["ignore", "pipe", "pipe"]
79
+ })
80
+ const timer = setTimeout(() => {
81
+ if (done) return
82
+ done = true
83
+ child.kill("SIGTERM")
84
+ setTimeout(() => { try { child.kill("SIGKILL") } catch {} }, 2000).unref()
85
+ resolve({ ok: false, stdout, stderr: "search timed out" })
86
+ }, timeoutMs)
87
+ child.stdout.on("data", (b) => { stdout += b })
88
+ child.stderr.on("data", (b) => { stderr += b })
89
+ child.on("error", (e) => {
90
+ if (done) return; done = true; clearTimeout(timer)
91
+ resolve({ ok: false, stdout, stderr: e.message })
92
+ })
93
+ child.on("close", (code) => {
94
+ if (done) return; done = true; clearTimeout(timer)
95
+ resolve({ ok: code === 0 || code === 1, stdout: stdout.trim(), stderr: stderr.trim() })
96
+ })
97
+ })
98
+ }
99
+
73
100
  async function runGlob(pattern, cwd, searchPath) {
74
101
  if (!pattern) return "pattern is required"
75
- const escaped = pattern.replace(/"/g, '\\"')
76
102
  const target = searchPath ? path.resolve(cwd, searchPath) : "."
77
- const command = `rg --files --glob "${escaped}" "${target}"`
78
- const out = await exec(command, { cwd, timeout: 15000, encoding: "utf8" }).catch((error) => ({
79
- stdout: error.stdout ?? "",
80
- stderr: error.stderr ?? error.message
81
- }))
82
- const text = `${out.stdout || ""}`.trim()
103
+ const { stdout } = await runRg(["--files", "--glob", pattern, target], cwd, 15000)
104
+ const text = stdout.trim()
83
105
  if (!text) return "no files matched"
84
106
  const lines = text.split("\n").filter(Boolean)
85
107
  if (lines.length > 200) {
@@ -90,31 +112,23 @@ async function runGlob(pattern, cwd, searchPath) {
90
112
 
91
113
  async function runGrep(pattern, cwd, options = {}) {
92
114
  if (!pattern) return "pattern is required"
93
- const parts = ["rg"]
94
- // Output mode
95
- if (options.multiline) parts.push("-U", "--multiline-dotall")
96
- if (options.outputMode === "count") parts.push("-c")
97
- else if (options.outputMode === "files") parts.push("-l")
98
- else parts.push("-n") // content mode (default)
99
- // Context
100
- if (options.beforeContext) parts.push("-B", String(options.beforeContext))
101
- if (options.afterContext) parts.push("-A", String(options.afterContext))
102
- if (options.context) parts.push("-C", String(options.context))
103
- // Filters
104
- if (options.type) parts.push("--type", options.type)
105
- if (options.glob) parts.push("--glob", `"${options.glob}"`)
106
- if (options.maxCount) parts.push("-m", String(options.maxCount))
107
- if (options.ignoreCase) parts.push("-i")
108
- const escaped = process.platform === "win32" ? `"${pattern}"` : `'${pattern}'`
109
- const target = options.path ? `"${path.resolve(cwd, options.path)}"` : "."
110
- parts.push(escaped, target)
111
- const command = parts.join(" ")
112
- const out = await exec(command, { cwd, timeout: 30000, encoding: "utf8" }).catch((error) => ({
113
- stdout: error.stdout ?? "",
114
- stderr: error.stderr ?? error.message
115
- }))
116
- let text = `${out.stdout || ""}${out.stderr || ""}`.trim()
117
- // Post-process: offset + head_limit for pagination
115
+ const args = []
116
+ if (options.multiline) args.push("-U", "--multiline-dotall")
117
+ if (options.outputMode === "count") args.push("-c")
118
+ else if (options.outputMode === "files") args.push("-l")
119
+ else args.push("-n")
120
+ if (options.beforeContext) args.push("-B", String(options.beforeContext))
121
+ if (options.afterContext) args.push("-A", String(options.afterContext))
122
+ if (options.context) args.push("-C", String(options.context))
123
+ if (options.type) args.push("--type", options.type)
124
+ if (options.glob) args.push("--glob", options.glob)
125
+ if (options.maxCount) args.push("-m", String(options.maxCount))
126
+ if (options.ignoreCase) args.push("-i")
127
+ args.push(pattern)
128
+ args.push(options.path ? path.resolve(cwd, options.path) : ".")
129
+ const { stdout, stderr } = await runRg(args, cwd)
130
+ let text = stdout.trim()
131
+ if (!text && stderr) text = `[search error] ${stderr}`
118
132
  if (text && (options.offset || options.headLimit)) {
119
133
  const lines = text.split("\n")
120
134
  const start = options.offset || 0
@@ -566,7 +580,8 @@ function builtinTools(config) {
566
580
  },
567
581
  async execute(args, ctx) {
568
582
  const command = String(args.command || "")
569
- const timeoutMs = Math.min(Math.max(Number(args.timeout) || BASH_TIMEOUT_MS, 1000), 600_000)
583
+ const configBashTimeout = Number(ctx.config?.tool?.bash_timeout_ms || BASH_TIMEOUT_MS)
584
+ const timeoutMs = Math.min(Math.max(Number(args.timeout) || configBashTimeout, 1000), 600_000)
570
585
 
571
586
  // 执行策略检查
572
587
  const policyCheck = checkBashAllowed(command, ctx.config)
@@ -1206,8 +1221,14 @@ function mcpTools() {
1206
1221
  description: `[mcp:${tool.server}] ${tool.description}`,
1207
1222
  inputSchema: tool.inputSchema,
1208
1223
  async execute(args, ctx) {
1209
- const result = await McpRegistry.callTool(tool.id, args || {}, ctx.signal || null)
1210
- return result.output
1224
+ try {
1225
+ const result = await McpRegistry.callTool(tool.id, args || {}, ctx.signal || null)
1226
+ return result.output
1227
+ } catch (error) {
1228
+ const reason = error.reason || "unknown"
1229
+ const server = error.server || tool.server
1230
+ return `[MCP Error: ${server} ${reason}] ${error.message}`
1231
+ }
1211
1232
  }
1212
1233
  }))
1213
1234
  }
@@ -1305,5 +1326,18 @@ export const ToolRegistry = {
1305
1326
  error: error.message
1306
1327
  }
1307
1328
  }
1329
+ },
1330
+
1331
+ refreshMcpTools() {
1332
+ if (!state.initialized || state.refreshing) return
1333
+ state.refreshing = true
1334
+ try {
1335
+ // Atomic replacement: build new list, then assign once
1336
+ const nonMcp = state.tools.filter((t) => !t.name.startsWith("mcp_"))
1337
+ const newMcpTools = mcpTools()
1338
+ state.tools = [...nonMcp, ...newMcpTools]
1339
+ } finally {
1340
+ state.refreshing = false
1341
+ }
1308
1342
  }
1309
1343
  }