@devcxl/opencode-cabbage 1.3.0 → 2.0.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 (89) hide show
  1. package/.codex-plugin/plugin.json +1 -1
  2. package/README.md +35 -23
  3. package/README.zh.md +22 -15
  4. package/assets/agents/dev-lifecycle.md +6 -0
  5. package/assets/prompts/bootstrap.md +4 -0
  6. package/assets/skills/flow-change/SKILL.md +109 -0
  7. package/assets/skills/flow-design/SKILL.md +15 -0
  8. package/assets/skills/flow-docs/SKILL.md +112 -0
  9. package/assets/skills/flow-incident/SKILL.md +98 -0
  10. package/assets/skills/flow-research/SKILL.md +9 -0
  11. package/assets/skills/flow-setup/SKILL.md +13 -0
  12. package/codex-skills/agent-dev-lifecycle/SKILL.md +2 -0
  13. package/codex-skills/flow-change/SKILL.md +109 -0
  14. package/codex-skills/flow-design/SKILL.md +18 -3
  15. package/codex-skills/flow-docs/SKILL.md +112 -0
  16. package/codex-skills/flow-incident/SKILL.md +98 -0
  17. package/codex-skills/flow-research/SKILL.md +12 -3
  18. package/codex-skills/flow-setup/SKILL.md +13 -0
  19. package/dist/index.d.ts +2 -5
  20. package/dist/index.js +1 -4
  21. package/dist/index.js.map +1 -1
  22. package/dist/plugin/agents.d.ts +20 -0
  23. package/dist/plugin/agents.js +79 -0
  24. package/dist/plugin/agents.js.map +1 -1
  25. package/dist/plugin/cabbage-cli.d.ts +28 -0
  26. package/dist/plugin/cabbage-cli.js +92 -0
  27. package/dist/plugin/cabbage-cli.js.map +1 -0
  28. package/dist/plugin/cabbage-tool.d.ts +103 -0
  29. package/dist/plugin/cabbage-tool.js +169 -0
  30. package/dist/plugin/cabbage-tool.js.map +1 -0
  31. package/dist/plugin/commands.d.ts +10 -0
  32. package/dist/plugin/commands.js +1 -1
  33. package/dist/plugin/commands.js.map +1 -1
  34. package/dist/plugin/continuation.d.ts +22 -0
  35. package/dist/plugin/continuation.js +233 -0
  36. package/dist/plugin/continuation.js.map +1 -0
  37. package/dist/plugin/goal-store.d.ts +42 -49
  38. package/dist/plugin/goal-store.js +35 -32
  39. package/dist/plugin/goal-store.js.map +1 -1
  40. package/dist/plugin/goal.d.ts +92 -28
  41. package/dist/plugin/goal.js +201 -158
  42. package/dist/plugin/goal.js.map +1 -1
  43. package/dist/plugin/install-dir.d.ts +13 -0
  44. package/dist/plugin/install-dir.js +46 -0
  45. package/dist/plugin/install-dir.js.map +1 -0
  46. package/dist/plugin/server.d.ts +4 -13
  47. package/dist/plugin/server.js +278 -423
  48. package/dist/plugin/server.js.map +1 -1
  49. package/dist/plugin/session-abort.d.ts +5 -0
  50. package/dist/plugin/session-abort.js +26 -0
  51. package/dist/plugin/session-abort.js.map +1 -0
  52. package/dist/plugin/skills.d.ts +15 -0
  53. package/dist/plugin/skills.js +47 -50
  54. package/dist/plugin/skills.js.map +1 -1
  55. package/dist/plugin.d.ts +2 -2
  56. package/dist/plugin.js +6 -2
  57. package/dist/plugin.js.map +1 -1
  58. package/dist/util/paths.d.ts +0 -6
  59. package/dist/util/paths.js +0 -15
  60. package/dist/util/paths.js.map +1 -1
  61. package/package.json +4 -7
  62. package/vendor/cabbage/MANIFEST.json +8 -0
  63. package/vendor/cabbage/cabbage_cli/__init__.py +1 -0
  64. package/vendor/cabbage/cabbage_cli/__main__.py +2 -0
  65. package/vendor/cabbage/cabbage_cli/assets/docs-site/.vitepress/config.ts +78 -0
  66. package/vendor/cabbage/cabbage_cli/assets/docs-site/README.md +31 -0
  67. package/vendor/cabbage/cabbage_cli/assets/docs-site/package.json +16 -0
  68. package/vendor/cabbage/cabbage_cli/assets/docs-site/pnpm-lock.yaml +2504 -0
  69. package/vendor/cabbage/cabbage_cli/assets/integrations/cabbage.yml +127 -0
  70. package/vendor/cabbage/cabbage_cli/assets/templates/adr.md +47 -0
  71. package/vendor/cabbage/cabbage_cli/assets/templates/api-design.md +45 -0
  72. package/vendor/cabbage/cabbage_cli/assets/templates/change-record.md +21 -0
  73. package/vendor/cabbage/cabbage_cli/assets/templates/database-design.md +37 -0
  74. package/vendor/cabbage/cabbage_cli/assets/templates/generic.md +20 -0
  75. package/vendor/cabbage/cabbage_cli/assets/templates/impact.md +41 -0
  76. package/vendor/cabbage/cabbage_cli/assets/templates/incident.md +48 -0
  77. package/vendor/cabbage/cabbage_cli/assets/templates/postmortem.md +43 -0
  78. package/vendor/cabbage/cabbage_cli/assets/templates/prd.md +71 -0
  79. package/vendor/cabbage/cabbage_cli/assets/templates/release-plan.md +45 -0
  80. package/vendor/cabbage/cabbage_cli/assets/templates/rfc.md +44 -0
  81. package/vendor/cabbage/cabbage_cli/assets/templates/security-review.md +35 -0
  82. package/vendor/cabbage/cabbage_cli/assets/templates/tasks.md +62 -0
  83. package/vendor/cabbage/cabbage_cli/assets/templates/tech-spec.md +87 -0
  84. package/vendor/cabbage/cabbage_cli/assets/templates/test-plan.md +44 -0
  85. package/vendor/cabbage/cabbage_cli/assets/workflows/change.yaml +11 -0
  86. package/vendor/cabbage/cabbage_cli/assets/workflows/incident.yaml +4 -0
  87. package/vendor/cabbage/cabbage_cli/cli.py +283 -0
  88. package/vendor/cabbage/cabbage_cli/core.py +606 -0
  89. package/vendor/cabbage/cabbage_cli/scaffold.py +313 -0
@@ -3,19 +3,22 @@ import { readFileSync } from "node:fs";
3
3
  import { initPrompts } from "./prompts.js";
4
4
  import { initBootstrap, getBootstrapContent } from "./bootstrap.js";
5
5
  import { loadCommands } from "./commands.js";
6
- import { setupSkillsDir } from "./skills.js";
7
- import { loadAgents } from "./agents.js";
6
+ import { setupSkillsDir, loadSkillEntries } from "./skills.js";
7
+ import { buildAgentPayloads } from "./agents.js";
8
8
  import { createAgentShellEnv } from "./shell.js";
9
- import { createGoalTool, mutateGoal, MAX_CONTINUATIONS, continuationPrompt, formatGoal, isBlockedReport, isDoneReport, snapshotTokenTotal, budgetReached } from "./goal.js";
10
- import { createGoalClient, createMetadataGoalStore } from "./goal-store.js";
11
- import { readIndex, updateFlowSession, removeFlowSession } from "../kernel/session-index.js";
12
- import { getContextBlock, formatContextBlock, CONTEXT_MARKER } from "../kernel/context.js";
9
+ import { buildGoalTool } from "./goal.js";
10
+ import { mutateGoal } from "./goal.js";
11
+ import { createSessionGoalStore } from "./goal-store.js";
12
+ import { setupCabbageCli } from "./cabbage-cli.js";
13
+ import { buildCabbageTool } from "./cabbage-tool.js";
14
+ import { queueContinuation, autoResume, SessionActivityTracker } from "./continuation.js";
15
+ import { markAborted, clearAborted, clearAllAborted } from "./session-abort.js";
16
+ import { readIndex, updateFlowSession } from "../kernel/session-index.js";
17
+ import { getContextBlock, CONTEXT_MARKER } from "../kernel/context.js";
13
18
  import { readProjectProfile } from "../kernel/profile.js";
14
- const abortedSessions = new Set();
15
- const continuationInFlight = new Set();
16
- const COMPACTION_THRESHOLD = 20;
17
- /** autoResume 跳过窗口:索引 30s 内更新过的 flow 不恢复(可能刚续接过,立即恢复会双发) */
18
- const AUTO_RESUME_SKIP_WINDOW_MS = 30_000;
19
+ /** bootstrap 注入标记(重复注入检测用) */
20
+ const BOOTSTRAP_MARKER = "EXTREMELY_IMPORTANT";
21
+ const ABORTED_SESSION_CLEANUP_INTERVAL = 1000 * 60 * 30;
19
22
  /** 从插件包根读取版本号(skills 安装目录按版本隔离) */
20
23
  function readPluginVersion(packageRoot) {
21
24
  try {
@@ -27,7 +30,7 @@ function readPluginVersion(packageRoot) {
27
30
  }
28
31
  }
29
32
  /**
30
- * 决定首条 user 消息注入内容:
33
+ * 决定系统上下文注入内容:
31
34
  * - Primary(goal 激活)→ bootstrap + Project Context 块
32
35
  * - 子 agent → 仅 Project Context 块
33
36
  * - 其他 → 不注入
@@ -41,444 +44,296 @@ export function buildFirstUserInjection(input) {
41
44
  }
42
45
  return parts.length > 0 ? parts.join("\n\n") : null;
43
46
  }
44
- export function configureGoalTools(config) {
45
- config.tools = { ...config.tools, goal: false };
46
- for (const [agentName, agent] of Object.entries(config.agent ?? {})) {
47
- if (!agent || typeof agent !== "object" || Array.isArray(agent))
48
- continue;
49
- const agentConfig = agent;
50
- const canUseGoal = agentName === "dev-lifecycle" || agentName === "goal-verify";
51
- agentConfig.tools = { ...agentConfig.tools, goal: canUseGoal };
52
- }
53
- }
54
47
  /**
55
- * 子会话活动闸:后台 subagent 运行时父会话保持 idle(结果稍后注入父会话)。
56
- * 此时续接会压过子代理结果注入,必须跳过本次续接,等子代理结束后父会话的
57
- * 下一次 busy→idle 循环再触发。查询失败视为 unknown → 保守跳过(不续接)。
48
+ * 刷新 goal 的会话配置快照(用户切换 agent/模型、或用户新输入时调用)。
49
+ * 数据源为 SessionInfo.agent/model(当前会话选择),V2 形状 {providerID, id, variant?}。
58
50
  */
59
- async function hasWorkingChildren(client, sessionID) {
60
- try {
61
- const childrenResult = await client.session.children({ sessionID });
62
- const children = Array.isArray(childrenResult?.data) ? childrenResult.data : [];
63
- if (children.length === 0)
64
- return false;
65
- const statusResult = await client.session.status();
66
- const statuses = statusResult?.data;
67
- if (!statuses || typeof statuses !== "object")
68
- return true;
69
- return children.some((child) => {
70
- const status = child?.id ? statuses[child.id] : null;
71
- return status?.type === "busy" || status?.type === "retry";
72
- });
73
- }
74
- catch (error) {
75
- // 查询失败 → 保守跳过(fail-closed);告警使长期故障可观测(否则续接会静默永久停止)
76
- console.warn(`[cabbage] child-session check failed for ${sessionID}:`, error?.message ?? error);
77
- return true;
78
- }
51
+ async function refreshSessionProfile(store, sessionID) {
52
+ await mutateGoal(store, sessionID, async (goal, session) => {
53
+ if (!goal)
54
+ return { goal, value: undefined };
55
+ const agent = session?.agent ?? goal.sessionProfile?.agent;
56
+ if (!agent)
57
+ return { goal, value: undefined };
58
+ const sessionModel = session?.model;
59
+ const model = sessionModel && sessionModel.providerID && sessionModel.id
60
+ ? { providerID: sessionModel.providerID, id: sessionModel.id, ...(sessionModel.variant ? { variant: sessionModel.variant } : {}) }
61
+ : goal.sessionProfile?.model;
62
+ goal.sessionProfile = { agent, ...(model ? { model } : {}) };
63
+ return { goal, value: undefined };
64
+ }).catch((err) => {
65
+ console.warn("[cabbage] session profile refresh failed:", err?.message ?? err);
66
+ });
79
67
  }
80
- export async function queueContinuation(client, store, sessionID, projectDir) {
81
- if (continuationInFlight.has(sessionID))
82
- return;
83
- continuationInFlight.add(sessionID);
84
- try {
85
- const decision = await mutateGoal(store, sessionID, async (goal) => {
86
- // 读改写与 message.updated 同锁串行,杜绝续接计数/状态被并发覆盖
68
+ /**
69
+ * 事件分发(V2 事件词汇):
70
+ * - execution.* → 子会话活动跟踪 + 轮次结束驱动续接
71
+ * - execution.interrupted(reason=user) → 中断即持久化暂停(不依赖内存标记)
72
+ * - inbox.enqueued(user) → 重置计数 + 刷新 profile + 清除中断标记
73
+ * - session.created → 父子关系跟踪
74
+ */
75
+ async function dispatchEvent(ctx, store, tracker, projectDir, evt) {
76
+ const data = evt.data ?? {};
77
+ const sessionID = typeof data.sessionID === "string" ? data.sessionID : undefined;
78
+ switch (evt.type) {
79
+ case "session.created": {
80
+ if (sessionID)
81
+ tracker.onCreated(sessionID, typeof data.parentID === "string" ? data.parentID : undefined);
82
+ return;
83
+ }
84
+ case "session.execution.started":
85
+ case "session.retry.scheduled": {
86
+ if (sessionID)
87
+ tracker.onBusy(sessionID);
88
+ return;
89
+ }
90
+ case "session.execution.succeeded": {
91
+ if (!sessionID)
92
+ return;
93
+ tracker.onIdle(sessionID);
94
+ const { session, goal } = await store.read(sessionID);
95
+ if (!session || session.parentID)
96
+ return;
87
97
  if (!goal || goal.status !== "active")
88
- return { goal, value: null };
89
- if (projectDir) {
90
- await updateFlowSession(projectDir, goal.parentIssueNumber, { status: goal.status, continuationCount: goal.continuationCount });
91
- }
92
- if (abortedSessions.has(sessionID)) {
93
- abortedSessions.delete(sessionID);
94
- goal.status = "paused";
95
- if (projectDir)
96
- await updateFlowSession(projectDir, goal.parentIssueNumber, { status: "paused" });
97
- return { goal, value: null };
98
- }
99
- if (goal.continuationCount >= MAX_CONTINUATIONS) {
100
- goal.status = "paused";
101
- if (projectDir)
102
- await updateFlowSession(projectDir, goal.parentIssueNumber, { status: "paused" });
103
- return { goal, value: null };
104
- }
105
- // 子会话活动闸:后台 subagent 仍 busy/retry 时跳过续接(父会话 idle 不代表任务静默)
106
- if (await hasWorkingChildren(client, sessionID)) {
107
- return { goal, value: null };
108
- }
109
- // 尾部静默检查:idle 事件可能竞态了后续消息/未落盘的回复。
110
- // 最后一条是 user 消息、或最后 assistant 未完成(无 completed 时间)→
111
- // 会话正在或即将忙碌,等下一次 idle 事件重新触发;无 assistant 消息则无 provider/model 可续。
112
- const messages = await store.messages(sessionID, 40);
113
- if (!messages)
114
- return { goal, value: null };
115
- const lastMessageInfo = messages.length > 0 ? messages[messages.length - 1]?.info : null;
116
- if (lastMessageInfo?.role === "user")
117
- return { goal, value: null };
118
- let lastAssistantMessage = null;
119
- for (let i = messages.length - 1; i >= 0; i -= 1) {
120
- const message = messages[i];
121
- if (message?.info?.role === "assistant") {
122
- lastAssistantMessage = message;
123
- break;
124
- }
125
- }
126
- const lastAssistantInfo = lastAssistantMessage?.info ?? null;
127
- const assistantCompleted = typeof lastAssistantInfo?.time === "object"
128
- && lastAssistantInfo.time !== null
129
- && lastAssistantInfo.time.completed
130
- && lastAssistantInfo.time.completed > 0;
131
- if (!lastAssistantInfo)
132
- return { goal, value: null };
133
- if (!assistantCompleted && !lastAssistantInfo.error)
134
- return { goal, value: null };
135
- if (!lastAssistantInfo.id)
136
- return { goal, value: null };
137
- // [BLOCKED:] 阻塞报告 / [DONE:] 完成报告:agent 明确信号 → 暂停 goal 请用户/验证介入
138
- // (防空转烧 token;完成报告后应由 dev-lifecycle 派 goal-verify 做最终独立验证)。
139
- // resume 后首次 tick 豁免(statusReason='resumed' 被消费清除),
140
- // 否则 resume 会立即被上一轮旧报告再次暂停成死路。
141
- const assistantText = (lastAssistantMessage?.parts ?? [])
142
- .filter((part) => part?.type === "text" && typeof part.text === "string")
143
- .map((part) => part.text)
144
- .join("\n");
145
- const agentSignaled = isBlockedReport(assistantText) || isDoneReport(assistantText);
146
- if (agentSignaled) {
147
- if (goal.statusReason === "resumed") {
148
- goal.statusReason = "";
149
- }
150
- else {
151
- goal.status = "paused";
152
- goal.statusReason = isBlockedReport(assistantText) ? "blocked report" : "done report";
153
- if (projectDir)
154
- await updateFlowSession(projectDir, goal.parentIssueNumber, { status: "paused" });
155
- return { goal, value: null };
156
- }
157
- }
158
- else if (goal.statusReason === "resumed") {
159
- // 豁免消费:resume 后的首次 tick 无论是否命中信号都清除 kickoff 标记。
160
- // 若不消费,首 tick 正常推进后 statusReason 残留,后续真实信号会被误豁免一次。
161
- goal.statusReason = "";
162
- }
163
- // Token 记账(快照法):最新完成 assistant 轮的 input+cache.read+output 即整段成本
164
- // (OpenCode 每轮 cache.read 已含此前所有付费 token)。goal 相对化:首次 tick
165
- // 用创建时刻前最后完成的 assistant 轮初始化 baseline;单调不减,context 收缩不回退。
166
- // compaction 不做分段(预算为护栏非账单)。
167
- let tokensBaseline = goal.tokensBaseline ?? 0;
168
- if (!(tokensBaseline > 0) && typeof goal.createdAt === "number") {
169
- for (const message of messages) {
170
- const info = message?.info;
171
- if (info?.role !== "assistant")
172
- continue;
173
- const completed = info.time?.completed;
174
- if (!(completed && completed > 0) || completed > goal.createdAt)
175
- continue;
176
- tokensBaseline = Math.max(tokensBaseline, snapshotTokenTotal(info.tokens));
177
- }
178
- }
179
- const tokensUsed = Math.max(goal.tokensUsed ?? 0, Math.max(0, snapshotTokenTotal(lastAssistantInfo.tokens) - tokensBaseline));
180
- goal.tokensBaseline = tokensBaseline;
181
- goal.tokensUsed = tokensUsed;
182
- // token 预算护栏:达限即暂停(用户需提高预算后 resume,护栏优先)
183
- if (budgetReached(goal)) {
98
+ return;
99
+ await queueContinuation(ctx, store, sessionID, projectDir, tracker);
100
+ return;
101
+ }
102
+ case "session.execution.failed": {
103
+ // 普通轮次错误不自动续接(与 V1 口径一致:[BLOCKED:] 协议覆盖卡点检测,
104
+ // 错误可能自愈,空转续接反而烧 token;若实测发现卡死再补 error 暂停)
105
+ if (sessionID)
106
+ tracker.onIdle(sessionID);
107
+ return;
108
+ }
109
+ case "session.execution.interrupted": {
110
+ if (!sessionID)
111
+ return;
112
+ tracker.onIdle(sessionID);
113
+ // 用户中断的两种 reason(v2.0.26 实测,v2.0.28 复验):API session.interrupt → "user";TUI Esc → "shutdown"。
114
+ // 两者都视为用户中断并暂停 goal;服务端关闭走 execution.failed(不命中本分支),
115
+ // 因此不会误暂停 autoResume 可恢复的 goal(详见 ADR 0010)。
116
+ if (data.reason !== "user" && data.reason !== "shutdown")
117
+ return;
118
+ markAborted(sessionID);
119
+ // abort 立即持久化 pause:不依赖内存标记。
120
+ // 否则 30 分钟清理窗口内无轮次结束事件、或插件重启时标记丢失,
121
+ // 用户主动中断的 goal 会被下次轮次/autoResume 自动续接(继续烧 token)。
122
+ void mutateGoal(store, sessionID, async (goal) => {
123
+ if (!goal || goal.status !== "active")
124
+ return { goal, value: undefined };
184
125
  goal.status = "paused";
185
- goal.statusReason = "token budget reached";
186
- if (projectDir)
187
- await updateFlowSession(projectDir, goal.parentIssueNumber, { status: "paused" });
188
- return { goal, value: null };
189
- }
190
- // 先写计数后发 prompt:崩溃窗口只会"少发一次续接"(等下次 idle 重试),
191
- // 不会"prompt 已发但计数未落盘"导致重启 autoResume 双发。
192
- goal.continuationCount++;
193
- if (projectDir)
194
- await updateFlowSession(projectDir, goal.parentIssueNumber, {
195
- continuationCount: goal.continuationCount,
196
- lastContinuedAt: Date.now(),
197
- });
198
- return { goal, value: { proceed: true, goal } };
199
- });
200
- if (!decision?.proceed || !decision.goal)
126
+ goal.statusReason = "aborted by user";
127
+ await updateFlowSession(projectDir, goal.parentIssueNumber, { status: "paused" }).catch(() => undefined);
128
+ return { goal, value: undefined };
129
+ }).catch((err) => {
130
+ console.warn("[cabbage] abort pause failed:", err?.message ?? err);
131
+ });
201
132
  return;
202
- // abort 二次检查:idle 与 abort 事件并发时,避免对已 abort 的会话发出续接
203
- if (abortedSessions.has(sessionID)) {
204
- abortedSessions.delete(sessionID);
133
+ }
134
+ case "session.inbox.enqueued": {
135
+ if (!sessionID)
136
+ return;
137
+ const item = data.item;
138
+ if (item?.type !== "user")
139
+ return;
140
+ clearAborted(sessionID);
205
141
  await mutateGoal(store, sessionID, async (goal) => {
206
142
  if (!goal)
207
- return { goal, value: null };
208
- goal.status = "paused";
209
- goal.continuationCount = Math.max(0, goal.continuationCount - 1);
210
- if (projectDir)
211
- await updateFlowSession(projectDir, goal.parentIssueNumber, { status: "paused" });
212
- return { goal, value: null };
143
+ return { goal, value: undefined };
144
+ goal.continuationCount = 0;
145
+ return { goal, value: undefined };
213
146
  }).catch(() => undefined);
147
+ await refreshSessionProfile(store, sessionID);
214
148
  return;
215
149
  }
216
- // 每 20 次续接主动 compact 会话历史(压缩失败不阻塞续接)
217
- if (decision.goal.continuationCount > 0 && decision.goal.continuationCount % COMPACTION_THRESHOLD === 0) {
218
- try {
219
- await client.session.promptAsync({
220
- sessionID,
221
- parts: [{ type: "text", text: "[compact] The session history is growing long. Summarize completed work and continue.", synthetic: true }],
222
- });
223
- }
224
- catch (err) {
225
- console.warn("[cabbage] compaction prompt failed:", err);
226
- }
150
+ case "session.agent.selected":
151
+ case "session.model.selected": {
152
+ if (sessionID)
153
+ await refreshSessionProfile(store, sessionID);
154
+ return;
227
155
  }
228
- const contextBlock = projectDir ? await getContextBlock(projectDir) : null;
229
- const contextText = contextBlock ? `${formatContextBlock(contextBlock)}\n\n` : "";
230
- try {
231
- await client.session.promptAsync({
232
- sessionID,
233
- parts: [{ type: "text", text: contextText + continuationPrompt(decision.goal.parentIssueNumber), synthetic: true }],
234
- ...(decision.goal.sessionProfile ? { agent: decision.goal.sessionProfile.agent, model: decision.goal.sessionProfile.model } : {}),
156
+ default:
157
+ return;
158
+ }
159
+ }
160
+ export async function setupCabbage(ctx, packageRoot) {
161
+ const sourceSkillsDir = path.join(packageRoot, "assets", "skills");
162
+ const promptsDir = path.join(packageRoot, "assets", "prompts");
163
+ const commandsDir = path.join(packageRoot, "assets", "commands");
164
+ const agentsDir = path.join(packageRoot, "assets", "agents");
165
+ const projectDir = ctx.location.directory;
166
+ const pluginVersion = readPluginVersion(packageRoot);
167
+ const skillsDir = await setupSkillsDir(sourceSkillsDir, promptsDir, pluginVersion);
168
+ // 预读磁盘内容:transform 回调是同步的,所有 IO 必须先完成
169
+ const skillEntries = await loadSkillEntries(skillsDir);
170
+ const commands = loadCommands(commandsDir, skillsDir);
171
+ const profile = await readProjectProfile(projectDir);
172
+ const testCommandToken = profile.testCommand?.split(" ")[0] ?? "";
173
+ const agentPayloads = buildAgentPayloads(agentsDir, testCommandToken);
174
+ initPrompts(packageRoot, projectDir);
175
+ await initBootstrap();
176
+ const store = createSessionGoalStore(ctx.session);
177
+ const tracker = new SessionActivityTracker();
178
+ // 内置 Cabbage 引擎(阶段二):按插件版本释放捆绑的 Python 包;
179
+ // 缺 Python 时工具返回可读错误,其余功能不受影响。
180
+ const cabbage = await setupCabbageCli(packageRoot, pluginVersion);
181
+ if (!cabbage.available) {
182
+ console.warn(`[cabbage] built-in change records unavailable: ${cabbage.reason}`);
183
+ }
184
+ // ── skills:注册为原生 skill(内容指向按版本隔离的安装目录,references 可解析) ──
185
+ await ctx.skill.transform((editor) => {
186
+ for (const skill of skillEntries) {
187
+ editor.add({
188
+ id: skill.id,
189
+ name: skill.name,
190
+ ...(skill.description ? { description: skill.description } : {}),
191
+ path: skill.path,
192
+ content: skill.content,
235
193
  });
236
194
  }
237
- catch (err) {
238
- // 发送失败 → 回滚计数(尽力;回滚失败只会少一次续接,方向安全)
239
- console.error("[cabbage] continuation prompt failed, rolling back counter:", err);
240
- await mutateGoal(store, sessionID, async (goal) => {
241
- if (!goal)
242
- return { goal, value: null };
243
- goal.continuationCount = Math.max(0, goal.continuationCount - 1);
244
- if (projectDir)
245
- await updateFlowSession(projectDir, goal.parentIssueNumber, { continuationCount: goal.continuationCount });
246
- return { goal, value: null };
247
- }).catch(() => undefined);
195
+ });
196
+ // ── commands:execute 式注册(模板文本作为 user prompt 投递,保留 delivery) ──
197
+ await ctx.command.transform((editor) => {
198
+ for (const cmd of commands) {
199
+ editor.add({
200
+ name: cmd.name,
201
+ ...(cmd.description ? { description: cmd.description } : {}),
202
+ execute: async ({ sessionID, prompt, delivery }) => {
203
+ if (cmd.agent) {
204
+ await ctx.session.switchAgent({ sessionID, agent: cmd.agent }).catch((err) => {
205
+ console.warn(`[cabbage] command /${cmd.name}: switchAgent failed:`, err?.message ?? err);
206
+ });
207
+ }
208
+ if (cmd.model || cmd.subtask) {
209
+ console.warn(`[cabbage] command /${cmd.name}: model/subtask frontmatter is not supported on OpenCode V2; ignoring`);
210
+ }
211
+ const args = (prompt?.text ?? "").trim();
212
+ const text = args ? `${cmd.template}\n\n${args}` : cmd.template;
213
+ await ctx.session.prompt({ sessionID, text, delivery });
214
+ },
215
+ });
216
+ }
217
+ });
218
+ // ── agents:缺失则新增(保留用户已存在的同名 agent),并为所有 agent 装配 goal 权限规则 ──
219
+ // 依赖 editor.update 对缺失 id 的 upsert 语义(新增 agent):该语义未见于 V2 文档,
220
+ // 是 v2.0.16 / v2.0.26 / v2.0.28 的实测行为,单测按同语义锁定 mock。若宿主改为忽略缺失 id,
221
+ // 下方注册校验会告警(可观测)。决策与替代方案见 ADR 0010。
222
+ await ctx.agent.transform((editor) => {
223
+ for (const payload of agentPayloads) {
224
+ if (editor.get(payload.key))
225
+ continue;
226
+ editor.update(payload.key, (agent) => {
227
+ Object.assign(agent, payload.fields);
228
+ });
229
+ }
230
+ const ids = new Set([
231
+ ...editor.list().map((a) => String(a.id)),
232
+ ...agentPayloads.map((p) => p.key),
233
+ ]);
234
+ // 工具门控双层策略的规则层:按 action 指定允许名单,其余 agent 显式 deny(后匹配优先)
235
+ const toolAccess = [
236
+ { action: "goal", allowed: new Set(["dev-lifecycle", "goal-verify"]) },
237
+ { action: "cabbage", allowed: new Set(["dev-lifecycle"]) },
238
+ ];
239
+ for (const id of ids) {
240
+ editor.update(id, (agent) => {
241
+ const permissions = Array.isArray(agent.permissions) ? agent.permissions : [];
242
+ let changed = false;
243
+ for (const { action, allowed } of toolAccess) {
244
+ if (permissions.some((rule) => rule?.action === action))
245
+ continue;
246
+ permissions.push({ action, resource: "*", effect: allowed.has(id) ? "allow" : "deny" });
247
+ changed = true;
248
+ }
249
+ if (changed)
250
+ agent.permissions = permissions;
251
+ });
252
+ }
253
+ });
254
+ // 注册校验:自有 agent 必须全部出现在宿主注册表中(兜住 upsert 语义变化的宿主升级)
255
+ try {
256
+ const registered = await ctx.agent.list();
257
+ const registeredIDs = new Set((registered?.data ?? []).map((agent) => String(agent.id)));
258
+ const missing = agentPayloads.map((payload) => payload.key).filter((key) => !registeredIDs.has(key));
259
+ if (missing.length > 0) {
260
+ console.warn(`[cabbage] agent registration incomplete (missing: ${missing.join(", ")}); ` +
261
+ "the host may have changed the undocumented update-upsert semantics (see ADR 0010)");
248
262
  }
249
263
  }
250
264
  catch (err) {
251
- console.error("[cabbage] continuation failed:", err);
252
- }
253
- finally {
254
- continuationInFlight.delete(sessionID);
265
+ console.warn("[cabbage] agent registration verification failed:", err?.message ?? err);
255
266
  }
256
- }
257
- export async function autoResume(client, store, projectDir) {
258
- const index = await readIndex(projectDir);
259
- for (const [parentIssueNumber, entry] of Object.entries(index.flows)) {
260
- if (entry.status !== "active")
261
- continue;
262
- // 重启双发防护:仅跳过"最近实际续接过"的 flow(lastContinuedAt 在发送续接时写入)。
263
- // 不用 updatedAt——它被 queueContinuation 开头的索引心跳无条件刷新,反映"最近 idle",
264
- // 崩溃前频繁 idle 的 flow 会被误跳过且无再武装(静默卡住)。
265
- // 无 lastContinuedAt 的旧 entry 不跳过(兼容升级前索引)。跳过时告警保证可观测。
266
- if (entry.lastContinuedAt && Date.now() - entry.lastContinuedAt < AUTO_RESUME_SKIP_WINDOW_MS) {
267
- console.warn(`[cabbage] auto-resume: skip #${parentIssueNumber} (continued ${Math.round((Date.now() - entry.lastContinuedAt) / 1000)}s ago; next idle event re-arms)`);
268
- continue;
267
+ // ── goal 工具与内置 cabbage 引擎:JSON Schema 输入 + permission gating + 工具内授权自检 ──
268
+ await ctx.tool.transform((editor) => {
269
+ editor.add(buildGoalTool(store, projectDir));
270
+ editor.add(buildCabbageTool(cabbage, projectDir));
271
+ });
272
+ // ── shell 环境:非交互 git、禁用凭据提示(V2 的 shell hook 为全局作用域) ──
273
+ await ctx.shell.hook("create.before", (event) => {
274
+ Object.assign(event.env, createAgentShellEnv());
275
+ });
276
+ // ── 系统上下文注入:goal 会话注入 bootstrap;goal/子 agent 注入 Project Context 块 ──
277
+ await ctx.session.hook("context", async (event) => {
278
+ let hasGoal = false;
279
+ let isSubAgent = false;
280
+ try {
281
+ const { goal, session } = await store.read(event.sessionID);
282
+ hasGoal = goal?.status === "active";
283
+ isSubAgent = !!session?.parentID;
269
284
  }
270
- const { goal } = await store.read(entry.sessionID);
271
- // 索引一致性自愈:entry 指向的 goal 已是别的 Flow(edit 换 Flow 后旧 entry 残留/rebind 失败)→
272
- // 删除陈旧 entry,防止每次重启对同一会话重复续接
273
- if (goal && goal.parentIssueNumber !== Number(parentIssueNumber)) {
274
- console.warn(`[cabbage] auto-resume: index #${parentIssueNumber} points to goal of Flow #${goal.parentIssueNumber}; removing stale entry`);
275
- await removeFlowSession(projectDir, Number(parentIssueNumber));
276
- continue;
285
+ catch {
286
+ return;
277
287
  }
278
- if (!goal || goal.status !== "active") {
279
- await updateFlowSession(projectDir, Number(parentIssueNumber), { status: "paused" });
280
- continue;
288
+ const contextBlock = await getContextBlock(projectDir);
289
+ const injection = buildFirstUserInjection({
290
+ hasGoal,
291
+ isSubAgent,
292
+ contextBlock,
293
+ bootstrap: getBootstrapContent(),
294
+ });
295
+ if (!injection)
296
+ return;
297
+ // 幂等:每个模型请求重建 system 数组时,已含标记则跳过(防重复膨胀)
298
+ const alreadyInjected = event.system.some((part) => typeof part.text === "string" && (part.text.includes(BOOTSTRAP_MARKER) || part.text.includes(CONTEXT_MARKER)));
299
+ if (alreadyInjected)
300
+ return;
301
+ event.system.push({ type: "text", text: injection });
302
+ });
303
+ // ── 事件订阅:单一流驱动子会话跟踪与自动续接 ──
304
+ const controller = new AbortController();
305
+ void (async () => {
306
+ try {
307
+ for await (const evt of ctx.event.subscribe({ signal: controller.signal })) {
308
+ await dispatchEvent(ctx, store, tracker, projectDir, evt).catch((err) => {
309
+ console.error("[cabbage] event dispatch failed:", err);
310
+ });
311
+ }
312
+ }
313
+ catch (err) {
314
+ if (!controller.signal.aborted) {
315
+ console.error("[cabbage] event stream ended:", err);
316
+ }
281
317
  }
318
+ })();
319
+ // ── 启动恢复:扫描索引,续接插件重启前处于 active 的 goal ──
320
+ void (async () => {
282
321
  try {
283
- await client.session.promptAsync({
284
- sessionID: entry.sessionID,
285
- parts: [{
286
- type: "text",
287
- text: `[auto-resume] Plugin restarted. Resuming previous goal:\n\n${formatGoal(goal)}\n\nContinue working.`,
288
- synthetic: true,
289
- }],
290
- ...(goal.sessionProfile ? { agent: goal.sessionProfile.agent, model: goal.sessionProfile.model } : {}),
291
- });
322
+ // 校验索引可读(损坏时 readIndex 返回空索引,不阻塞启动)
323
+ await readIndex(projectDir);
324
+ await autoResume(ctx, store, projectDir);
292
325
  }
293
326
  catch (err) {
294
327
  console.error("[cabbage] auto-resume failed:", err);
295
328
  }
296
- }
297
- }
298
- const ABORTED_SESSION_CLEANUP_INTERVAL = 1000 * 60 * 30;
299
- let abortedCleanupTimer = null;
300
- function startPeriodicCleanup() {
301
- if (abortedCleanupTimer)
302
- clearInterval(abortedCleanupTimer);
303
- abortedCleanupTimer = setInterval(() => {
304
- abortedSessions.clear();
329
+ })();
330
+ // 中断标记定期清理(防长生命周期进程内存泄漏)
331
+ const abortedCleanupTimer = setInterval(() => {
332
+ clearAllAborted();
305
333
  }, ABORTED_SESSION_CLEANUP_INTERVAL);
306
- }
307
- export function createOpencodeCabbage(packageRoot) {
308
- return async (ctx, _options) => {
309
- const sourceSkillsDir = path.join(packageRoot, "assets", "skills");
310
- const promptsDir = path.join(packageRoot, "assets", "prompts");
311
- const commandsDir = path.join(packageRoot, "assets", "commands");
312
- const skillsDir = await setupSkillsDir(sourceSkillsDir, promptsDir, readPluginVersion(packageRoot));
313
- const projectDir = ctx.worktree || ctx.directory;
314
- const v1Client = ctx.client._client;
315
- const goalClient = createGoalClient(ctx.serverUrl, v1Client);
316
- const goalStore = createMetadataGoalStore(goalClient);
317
- const goalTool = createGoalTool(goalStore, projectDir);
318
- const agentsDir = path.join(packageRoot, "assets", "agents");
319
- await initPrompts(packageRoot, projectDir);
320
- await initBootstrap();
321
- startPeriodicCleanup();
322
- void autoResume(goalClient, goalStore, projectDir).catch(err => {
323
- console.error("[cabbage] auto-resume failed:", err);
324
- });
325
- return {
326
- tool: {
327
- goal: goalTool,
328
- },
329
- config: async (rawConfig) => {
330
- const config = rawConfig;
331
- config.skills = config.skills || {};
332
- config.skills.paths = config.skills.paths || [];
333
- if (!config.skills.paths.includes(skillsDir)) {
334
- config.skills.paths.push(skillsDir);
335
- }
336
- config.command = config.command || {};
337
- for (const cmd of loadCommands(commandsDir, skillsDir)) {
338
- if (config.command[cmd.name])
339
- continue;
340
- config.command[cmd.name] = {
341
- template: cmd.template,
342
- description: cmd.description,
343
- agent: cmd.agent,
344
- model: cmd.model,
345
- subtask: cmd.subtask,
346
- };
347
- }
348
- config.agent = config.agent || {};
349
- // 渲染 permission 占位符:`<profile-test-command>` → Profile.testCommand 首 token(§9.1)
350
- const profile = await readProjectProfile(projectDir);
351
- const testCommandToken = profile.testCommand?.split(" ")[0] ?? "";
352
- const renderPermission = (perm) => {
353
- if (!perm)
354
- return perm;
355
- const renderRules = (rules) => {
356
- if (!rules)
357
- return rules;
358
- const out = {};
359
- for (const [pattern, action] of Object.entries(rules)) {
360
- if (pattern.includes("<profile-test-command>") && testCommandToken === "") {
361
- // Profile 未配置测试命令 → 删除占位规则,避免渲染成 "*" 覆盖兜底 deny
362
- continue;
363
- }
364
- out[pattern.replaceAll("<profile-test-command>", testCommandToken)] = action;
365
- }
366
- return out;
367
- };
368
- const out = { ...perm };
369
- if (perm.bash && typeof perm.bash === "object" && !Array.isArray(perm.bash)) {
370
- out.bash = renderRules(perm.bash);
371
- }
372
- return out;
373
- };
374
- for (const agent of loadAgents(agentsDir)) {
375
- if (config.agent[agent.key])
376
- continue;
377
- config.agent[agent.key] = {
378
- description: agent.description,
379
- mode: agent.mode,
380
- color: agent.color,
381
- prompt: agent.prompt,
382
- permission: renderPermission(agent.permission),
383
- shell: {
384
- env: createAgentShellEnv(),
385
- },
386
- };
387
- }
388
- configureGoalTools(config);
389
- },
390
- "experimental.chat.messages.transform": async (_input, output) => {
391
- if (!output.messages.length)
392
- return;
393
- const firstUser = output.messages.find(m => m.info.role === "user");
394
- if (!firstUser || !firstUser.parts.length)
395
- return;
396
- if (firstUser.parts.some(p => p.type === "text" && (p.text.includes("EXTREMELY_IMPORTANT") || p.text.includes(CONTEXT_MARKER))))
397
- return;
398
- const sessionID = output.messages[0].info.sessionID;
399
- const { goal, session } = await goalStore.read(sessionID);
400
- const contextBlock = await getContextBlock(projectDir);
401
- const injection = buildFirstUserInjection({
402
- hasGoal: goal?.status === "active",
403
- isSubAgent: !!session?.parentID,
404
- contextBlock,
405
- bootstrap: getBootstrapContent(),
406
- });
407
- if (injection) {
408
- firstUser.parts.unshift({ type: "text", text: injection });
409
- }
410
- },
411
- async event({ event }) {
412
- const evt = event;
413
- if (evt.type === "session.error") {
414
- const errorName = evt.properties?.error?.name;
415
- const sessionID = evt.properties.sessionID;
416
- if (errorName === "MessageAbortedError" && sessionID) {
417
- abortedSessions.add(sessionID);
418
- // abort 立即持久化 pause:不依赖内存标记。
419
- // 否则 30 分钟清理窗口内无 idle 事件、或插件重启时标记丢失,
420
- // 用户主动中断的 goal 会被下次 idle/autoResume 自动续接(继续烧 token)。
421
- void mutateGoal(goalStore, sessionID, async (goal) => {
422
- if (!goal || goal.status !== "active")
423
- return { goal, value: undefined };
424
- goal.status = "paused";
425
- goal.statusReason = "aborted by user";
426
- if (projectDir)
427
- await updateFlowSession(projectDir, goal.parentIssueNumber, { status: "paused" });
428
- return { goal, value: undefined };
429
- }).catch((err) => {
430
- console.warn("[cabbage] abort pause failed:", err?.message ?? err);
431
- });
432
- }
433
- }
434
- if (evt.type === "session.status" && evt.properties?.status?.type === "idle") {
435
- const sessionID = evt.properties.sessionID;
436
- if (sessionID) {
437
- try {
438
- const { session } = await goalStore.read(sessionID);
439
- if (session?.parentID)
440
- return;
441
- }
442
- catch (err) {
443
- console.error("[cabbage] session lookup failed:", err);
444
- return;
445
- }
446
- await queueContinuation(goalClient, goalStore, sessionID, projectDir).catch(err => {
447
- console.error("[cabbage] continuation failed:", err);
448
- });
449
- }
450
- }
451
- if (evt.type === "message.updated" && evt.properties?.info?.role === "user") {
452
- const sessionID = evt.properties.sessionID;
453
- if (sessionID && !continuationInFlight.has(sessionID)) {
454
- abortedSessions.delete(sessionID);
455
- const info = evt.properties.info;
456
- await mutateGoal(goalStore, sessionID, async (goal) => {
457
- if (!goal)
458
- return { goal, value: undefined };
459
- goal.continuationCount = 0;
460
- // 刷新用户最后使用的 agent/模型快照(用户在 TUI 切换 agent/模型后发消息)
461
- if (info?.agent) {
462
- goal.sessionProfile = {
463
- agent: info.agent,
464
- model: info.model?.providerID ? { providerID: info.model.providerID, modelID: info.model.modelID } : goal.sessionProfile?.model,
465
- };
466
- }
467
- return { goal, value: undefined };
468
- });
469
- }
470
- }
471
- if (evt.type === "session.updated") {
472
- const sessionID = evt.properties?.sessionID;
473
- if (sessionID) {
474
- const { goal } = await goalStore.read(sessionID);
475
- if (goal?.status === "complete") {
476
- await updateFlowSession(projectDir, goal.parentIssueNumber, { status: "completed" });
477
- }
478
- }
479
- }
480
- },
481
- };
334
+ return () => {
335
+ controller.abort();
336
+ clearInterval(abortedCleanupTimer);
482
337
  };
483
338
  }
484
339
  //# sourceMappingURL=server.js.map