opencode-froggy 0.12.0 → 1.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 (45) hide show
  1. package/README.md +10 -9
  2. package/command/diff-summary.md +27 -8
  3. package/command/doc-changes.md +1 -0
  4. package/command/review-changes.md +1 -0
  5. package/command/review-pr.md +1 -0
  6. package/command/simplify-changes.md +1 -0
  7. package/dist/command-installer.d.ts +6 -0
  8. package/dist/command-installer.js +49 -0
  9. package/dist/command-installer.test.d.ts +1 -0
  10. package/dist/command-installer.test.js +58 -0
  11. package/dist/config-paths.d.ts +2 -0
  12. package/dist/config-paths.js +6 -0
  13. package/dist/config-paths.test.js +16 -0
  14. package/dist/index.d.ts +3 -3
  15. package/dist/index.js +326 -259
  16. package/dist/index.test.js +3 -3
  17. package/dist/loaders.d.ts +2 -0
  18. package/dist/loaders.js +1 -0
  19. package/dist/session-children.d.ts +14 -0
  20. package/dist/session-children.js +27 -0
  21. package/dist/session-children.test.d.ts +1 -0
  22. package/dist/session-children.test.js +25 -0
  23. package/dist/skill-activation.js +1 -1
  24. package/dist/tools/agent-promote.d.ts +38 -11
  25. package/dist/tools/agent-promote.js +35 -33
  26. package/dist/tools/blockchain/eth-address-balance.d.ts +18 -8
  27. package/dist/tools/blockchain/eth-address-balance.js +15 -14
  28. package/dist/tools/blockchain/eth-address-txs.d.ts +22 -10
  29. package/dist/tools/blockchain/eth-address-txs.js +19 -18
  30. package/dist/tools/blockchain/eth-token-transfers.d.ts +22 -10
  31. package/dist/tools/blockchain/eth-token-transfers.js +19 -18
  32. package/dist/tools/blockchain/eth-transaction.d.ts +30 -14
  33. package/dist/tools/blockchain/eth-transaction.js +24 -21
  34. package/dist/tools/gitingest.d.ts +27 -15
  35. package/dist/tools/gitingest.js +28 -21
  36. package/dist/tools/index.d.ts +1 -1
  37. package/dist/tools/index.js +1 -1
  38. package/dist/tools/list-child-sessions.d.ts +11 -7
  39. package/dist/tools/list-child-sessions.js +22 -17
  40. package/dist/tools/pdf-to-markdown.d.ts +18 -8
  41. package/dist/tools/pdf-to-markdown.js +20 -13
  42. package/dist/tools/prompt-session.d.ts +26 -11
  43. package/dist/tools/prompt-session.js +23 -28
  44. package/package.json +13 -5
  45. package/tui.ts +24 -0
package/dist/index.js CHANGED
@@ -1,298 +1,365 @@
1
1
  import { dirname, join } from "node:path";
2
2
  import { fileURLToPath } from "node:url";
3
- import { loadAgents, loadCommands, loadHooks, loadSkills, mergeHooks, } from "./loaders";
4
- import { getGlobalHookDir, getProjectHookDir } from "./config-paths";
3
+ import { Plugin } from "@opencode/plugin";
4
+ import { loadAgents, loadHooks, loadSkills, mergeHooks, } from "./loaders";
5
+ import { getGlobalAgentDir, getGlobalCommandDir, getGlobalHookDir, getProjectHookDir } from "./config-paths";
5
6
  import { hasCodeExtension } from "./code-files";
6
7
  import { log } from "./logger";
7
8
  import { executeBashAction, DEFAULT_BASH_TIMEOUT, } from "./bash-executor";
8
- import { gitingestTool, pdfToMarkdownTool, createPromptSessionTool, createListChildSessionsTool, createAgentPromoteTool, getPromotedAgents, ethTransactionTool, ethAddressTxsTool, ethAddressBalanceTool, ethTokenTransfersTool, } from "./tools";
9
+ import { gitingestTool, pdfToMarkdownTool, createPromptSessionTool, createListChildSessionsTool, createAgentPromoteTool, setPromotedAgent, getPromotedAgents, AGENT_PROMOTE_STORAGE_KEY, ethTransactionTool, ethAddressTxsTool, ethAddressBalanceTool, ethTokenTransfersTool, } from "./tools";
10
+ import { buildSkillActivationBlock } from "./skill-activation";
11
+ import { installBundledFiles } from "./command-installer";
12
+ import { ChildSessionTracker } from "./session-children";
9
13
  export { parseFrontmatter, loadAgents, loadCommands } from "./loaders";
10
14
  export { buildSkillActivationBlock } from "./skill-activation";
11
- import { buildSkillActivationBlock } from "./skill-activation";
12
- // ============================================================================
13
- // CONSTANTS
14
- // ============================================================================
15
15
  const __filename = fileURLToPath(import.meta.url);
16
16
  const __dirname = dirname(__filename);
17
17
  const PLUGIN_ROOT = join(__dirname, "..");
18
18
  const AGENT_DIR = join(PLUGIN_ROOT, "agent");
19
19
  const COMMAND_DIR = join(PLUGIN_ROOT, "command");
20
20
  const SKILL_DIR = join(PLUGIN_ROOT, "skill");
21
- // ============================================================================
22
- // PLUGIN
23
- // ============================================================================
24
- const SmartfrogPlugin = async (ctx) => {
25
- const agents = loadAgents(AGENT_DIR);
26
- const commands = loadCommands(COMMAND_DIR);
27
- const skills = loadSkills(SKILL_DIR);
28
- const globalHooks = loadHooks(getGlobalHookDir());
29
- const projectHooks = loadHooks(getProjectHookDir(ctx.directory));
30
- const hooks = mergeHooks(globalHooks, projectHooks);
31
- const modifiedCodeFiles = new Map();
32
- const pendingToolArgs = new Map();
33
- const skillsWithTriggers = skills.filter(s => s.useWhen);
34
- const skillActivationBlock = skillsWithTriggers.length > 0
35
- ? buildSkillActivationBlock(skillsWithTriggers)
36
- : null;
37
- log("[init] Plugin loaded", {
38
- agents: Object.keys(agents),
39
- commands: Object.keys(commands),
40
- skills: skills.map(s => s.name),
41
- skillsWithTriggers: skillsWithTriggers.map(s => s.name),
42
- hooks: Array.from(hooks.keys()),
43
- tools: [
44
- "gitingest",
45
- "pdf-to-markdown",
46
- "agent-promote",
47
- "eth-transaction",
48
- "eth-address-txs",
49
- "eth-address-balance",
50
- "eth-token-transfers",
51
- ],
52
- });
53
- async function executeHookActions(hook, sessionID, extraLog, options) {
54
- const prefix = `[hook:${hook.event}]`;
55
- const canBlock = options?.canBlock ?? false;
56
- const conditions = hook.conditions ?? [];
57
- for (const condition of conditions) {
58
- if (condition === "isMainSession") {
59
- const sessionInfo = await ctx.client.session.get({ path: { id: sessionID } });
60
- if (sessionInfo.data?.parentID) {
61
- log(`${prefix} condition not met, skipping`, { sessionID, condition });
62
- return { blocked: false };
63
- }
64
- }
65
- if (condition === "hasCodeChange") {
66
- const files = extraLog?.files;
67
- if (!files || !files.some(hasCodeExtension)) {
68
- log(`${prefix} condition not met, skipping`, { sessionID, condition });
69
- return { blocked: false };
21
+ const TRACKED_FILE_TOOLS = new Set(["write", "edit", "patch"]);
22
+ function asRecord(value) {
23
+ if (value && typeof value === "object" && !Array.isArray(value)) {
24
+ return value;
25
+ }
26
+ return {};
27
+ }
28
+ function readSessionID(event) {
29
+ const data = asRecord(asRecord(event).data);
30
+ const sessionID = data.sessionID;
31
+ return typeof sessionID === "string" ? sessionID : undefined;
32
+ }
33
+ function readParentID(event) {
34
+ const data = asRecord(asRecord(event).data);
35
+ const parentID = data.parentID;
36
+ return typeof parentID === "string" ? parentID : undefined;
37
+ }
38
+ function readTitle(event) {
39
+ const data = asRecord(asRecord(event).data);
40
+ const title = data.title;
41
+ return typeof title === "string" ? title : undefined;
42
+ }
43
+ export default Plugin.define({
44
+ id: "opencode-froggy",
45
+ async setup(ctx) {
46
+ const agents = loadAgents(AGENT_DIR);
47
+ const skills = loadSkills(SKILL_DIR);
48
+ const emptyInstall = { installed: [], skipped: [], updated: [] };
49
+ let installedCommands = { ...emptyInstall };
50
+ let installedAgents = { ...emptyInstall };
51
+ try {
52
+ installedCommands = installBundledFiles(COMMAND_DIR, getGlobalCommandDir());
53
+ }
54
+ catch (error) {
55
+ log("[init] failed to install commands", { error: String(error) });
56
+ }
57
+ try {
58
+ installedAgents = installBundledFiles(AGENT_DIR, getGlobalAgentDir());
59
+ }
60
+ catch (error) {
61
+ log("[init] failed to install agents", { error: String(error) });
62
+ }
63
+ try {
64
+ await ctx.agent.reload();
65
+ }
66
+ catch (error) {
67
+ log("[init] failed to reload agents", { error: String(error) });
68
+ }
69
+ try {
70
+ await ctx.command.reload();
71
+ }
72
+ catch (error) {
73
+ log("[init] failed to reload commands", { error: String(error) });
74
+ }
75
+ const globalHooks = loadHooks(getGlobalHookDir());
76
+ const projectHooks = loadHooks(getProjectHookDir(ctx.location.directory));
77
+ const hooks = mergeHooks(globalHooks, projectHooks);
78
+ const modifiedCodeFiles = new Map();
79
+ const tracker = new ChildSessionTracker();
80
+ try {
81
+ const stored = await ctx.storage.get(AGENT_PROMOTE_STORAGE_KEY);
82
+ if (stored && typeof stored === "object" && !Array.isArray(stored)) {
83
+ for (const [name, mode] of Object.entries(stored)) {
84
+ if (mode === "primary" || mode === "subagent" || mode === "all") {
85
+ setPromotedAgent(name, mode);
86
+ }
70
87
  }
71
88
  }
72
89
  }
73
- log(`${prefix} starting`, {
74
- sessionID,
75
- conditions,
76
- actions: hook.actions.length,
77
- ...extraLog
90
+ catch (error) {
91
+ log("[init] failed to load promoted agents", { error: String(error) });
92
+ }
93
+ const skillsWithTriggers = skills.filter((s) => s.useWhen);
94
+ const skillActivationBlock = skillsWithTriggers.length > 0 ? buildSkillActivationBlock(skillsWithTriggers) : null;
95
+ log("[init] Plugin loaded", {
96
+ agents: Object.keys(agents),
97
+ commandsInstalled: installedCommands.installed,
98
+ commandsUpdated: installedCommands.updated,
99
+ agentsInstalled: installedAgents.installed,
100
+ agentsUpdated: installedAgents.updated,
101
+ skills: skills.map((s) => s.name),
102
+ skillsWithTriggers: skillsWithTriggers.map((s) => s.name),
103
+ hooks: Array.from(hooks.keys()),
104
+ tools: [
105
+ "gitingest",
106
+ "pdf-to-markdown",
107
+ "prompt-session",
108
+ "list-child-sessions",
109
+ "agent-promote",
110
+ "eth-transaction",
111
+ "eth-address-txs",
112
+ "eth-address-balance",
113
+ "eth-token-transfers",
114
+ ],
78
115
  });
79
- for (const action of hook.actions) {
80
- try {
81
- if ("command" in action) {
82
- const { name, args = "" } = typeof action.command === "string"
83
- ? { name: action.command }
84
- : action.command;
85
- const { agent, model } = commands[name] ?? {};
86
- log(`${prefix} executing command`, { command: name, args, agent, model });
87
- const result = await ctx.client.session.command({
88
- path: { id: sessionID },
89
- body: {
90
- command: name,
91
- arguments: args,
92
- agent,
93
- model,
94
- },
95
- query: { directory: ctx.directory },
96
- });
97
- log(`${prefix} command result`, { command: name, status: result.response?.status, error: result.error });
98
- }
99
- else if ("tool" in action) {
100
- log(`${prefix} executing tool`, { tool: action.tool.name });
101
- const result = await ctx.client.session.prompt({
102
- path: { id: sessionID },
103
- body: { parts: [{ type: "text", text: `Use the ${action.tool.name} tool with these arguments: ${JSON.stringify(action.tool.args)}` }] },
104
- query: { directory: ctx.directory },
105
- });
106
- log(`${prefix} tool result`, { tool: action.tool.name, status: result.response?.status, error: result.error });
107
- }
108
- else if ("bash" in action) {
109
- const { command, timeout } = typeof action.bash === "string"
110
- ? { command: action.bash, timeout: DEFAULT_BASH_TIMEOUT }
111
- : { command: action.bash.command, timeout: action.bash.timeout ?? DEFAULT_BASH_TIMEOUT };
112
- const startTime = Date.now();
113
- log(`${prefix} executing bash`, { command, timeout });
114
- const bashContext = {
115
- session_id: sessionID,
116
- event: hook.event,
117
- cwd: ctx.directory,
118
- files: extraLog?.files,
119
- tool_name: extraLog?.tool_name,
120
- tool_args: extraLog?.tool_args,
121
- };
122
- const result = await executeBashAction(command, timeout, bashContext, ctx.directory);
123
- const duration = Date.now() - startTime;
124
- const statusIcon = result.exitCode === 0 ? "✓" : "✗";
125
- const hookMessage = [
126
- `[BASH HOOK ${statusIcon}] ${command}`,
127
- `Exit: ${result.exitCode} | Duration: ${duration}ms`,
128
- result.stdout.trim() ? `Stdout: ${result.stdout.slice(0, 500).trim()}` : null,
129
- result.stderr.trim() ? `Stderr: ${result.stderr.slice(0, 500).trim()}` : null,
130
- ].filter(Boolean).join("\n");
131
- await ctx.client.session.prompt({
132
- path: { id: sessionID },
133
- body: {
134
- noReply: true,
135
- parts: [{ type: "text", text: hookMessage }],
136
- },
137
- query: { directory: ctx.directory },
138
- }).catch((err) => {
139
- log(`${prefix} failed to send hook message`, { error: String(err) });
140
- });
141
- if (result.exitCode === 2) {
142
- log(`${prefix} bash exit code 2`, { stderr: result.stderr, canBlock });
143
- if (canBlock) {
144
- const blockReason = result.stderr.trim() || "Blocked by hook";
145
- return { blocked: true, blockReason };
116
+ async function executeHookActions(hook, sessionID, extraLog, options) {
117
+ const prefix = `[hook:${hook.event}]`;
118
+ const canBlock = options?.canBlock ?? false;
119
+ const conditions = hook.conditions ?? [];
120
+ for (const condition of conditions) {
121
+ if (condition === "isMainSession") {
122
+ try {
123
+ const sessionInfo = (await ctx.session.get({ sessionID }));
124
+ if (sessionInfo?.parentID) {
125
+ log(`${prefix} condition not met, skipping`, { sessionID, condition });
126
+ return { blocked: false };
146
127
  }
147
- return { blocked: false };
148
128
  }
149
- if (result.exitCode !== 0) {
150
- log(`${prefix} bash failed (non-blocking)`, { exitCode: result.exitCode, stderr: result.stderr });
129
+ catch (error) {
130
+ log(`${prefix} failed to check session, continuing`, { error: String(error) });
151
131
  }
152
- else {
153
- log(`${prefix} bash completed`, { stdout: result.stdout.slice(0, 200) });
132
+ }
133
+ if (condition === "hasCodeChange") {
134
+ const files = extraLog?.files;
135
+ if (!files || !files.some(hasCodeExtension)) {
136
+ log(`${prefix} condition not met, skipping`, { sessionID, condition });
137
+ return { blocked: false };
154
138
  }
155
139
  }
156
140
  }
157
- catch (error) {
158
- log(`${prefix} action failed, continuing`, { error: String(error) });
141
+ log(`${prefix} starting`, {
142
+ sessionID,
143
+ conditions,
144
+ actions: hook.actions.length,
145
+ ...extraLog,
146
+ });
147
+ for (const action of hook.actions) {
148
+ try {
149
+ if ("command" in action) {
150
+ const { name, args = "" } = typeof action.command === "string" ? { name: action.command } : action.command;
151
+ log(`${prefix} executing command`, { command: name, args });
152
+ await ctx.session.command({ sessionID, command: name, text: args });
153
+ }
154
+ else if ("tool" in action) {
155
+ log(`${prefix} executing tool`, { tool: action.tool.name });
156
+ await ctx.session.prompt({
157
+ sessionID,
158
+ text: `Use the ${action.tool.name} tool with these arguments: ${JSON.stringify(action.tool.args)}`,
159
+ });
160
+ }
161
+ else if ("bash" in action) {
162
+ const { command, timeout } = typeof action.bash === "string"
163
+ ? { command: action.bash, timeout: DEFAULT_BASH_TIMEOUT }
164
+ : { command: action.bash.command, timeout: action.bash.timeout ?? DEFAULT_BASH_TIMEOUT };
165
+ const startTime = Date.now();
166
+ log(`${prefix} executing bash`, { command, timeout });
167
+ const bashContext = {
168
+ session_id: sessionID,
169
+ event: hook.event,
170
+ cwd: ctx.location.directory,
171
+ files: extraLog?.files,
172
+ tool_name: extraLog?.tool_name,
173
+ tool_args: extraLog?.tool_args,
174
+ };
175
+ const result = await executeBashAction(command, timeout, bashContext, ctx.location.directory);
176
+ const duration = Date.now() - startTime;
177
+ const statusIcon = result.exitCode === 0 ? "✓" : "✗";
178
+ const hookMessage = [
179
+ `[BASH HOOK ${statusIcon}] ${command}`,
180
+ `Exit: ${result.exitCode} | Duration: ${duration}ms`,
181
+ result.stdout.trim() ? `Stdout: ${result.stdout.slice(0, 500).trim()}` : null,
182
+ result.stderr.trim() ? `Stderr: ${result.stderr.slice(0, 500).trim()}` : null,
183
+ ]
184
+ .filter(Boolean)
185
+ .join("\n");
186
+ await ctx.session
187
+ .synthetic({ sessionID, text: hookMessage })
188
+ .catch((err) => {
189
+ log(`${prefix} failed to send hook message`, { error: String(err) });
190
+ });
191
+ if (result.exitCode === 2) {
192
+ log(`${prefix} bash exit code 2`, { stderr: result.stderr, canBlock });
193
+ if (canBlock) {
194
+ const blockReason = result.stderr.trim() || "Blocked by hook";
195
+ return { blocked: true, blockReason };
196
+ }
197
+ return { blocked: false };
198
+ }
199
+ if (result.exitCode !== 0) {
200
+ log(`${prefix} bash failed (non-blocking)`, {
201
+ exitCode: result.exitCode,
202
+ stderr: result.stderr,
203
+ });
204
+ }
205
+ else {
206
+ log(`${prefix} bash completed`, { stdout: result.stdout.slice(0, 200) });
207
+ }
208
+ }
209
+ }
210
+ catch (error) {
211
+ log(`${prefix} action failed, continuing`, { error: String(error) });
212
+ }
159
213
  }
160
- }
161
- log(`${prefix} completed`);
162
- return { blocked: false };
163
- }
164
- async function triggerHooks(event, sessionID, extraLog, options) {
165
- const eventHooks = hooks.get(event);
166
- if (!eventHooks)
214
+ log(`${prefix} completed`);
167
215
  return { blocked: false };
168
- for (const hook of eventHooks) {
169
- const result = await executeHookActions(hook, sessionID, extraLog, options);
170
- if (result.blocked)
171
- return result;
172
216
  }
173
- return { blocked: false };
174
- }
175
- async function triggerToolHooks(phase, toolName, sessionID, toolArgs) {
176
- const canBlock = phase === "before";
177
- const extraLog = { tool_name: toolName, tool_args: toolArgs };
178
- const wildcardEvent = `tool.${phase}.*`;
179
- const wildcardResult = await triggerHooks(wildcardEvent, sessionID, extraLog, { canBlock });
180
- if (wildcardResult.blocked)
181
- return wildcardResult;
182
- const specificEvent = `tool.${phase}.${toolName}`;
183
- const specificResult = await triggerHooks(specificEvent, sessionID, extraLog, { canBlock });
184
- return specificResult;
185
- }
186
- return {
187
- config: async (config) => {
188
- const loadedAgents = loadAgents(AGENT_DIR);
189
- for (const [name, mode] of getPromotedAgents()) {
190
- if (loadedAgents[name]) {
191
- loadedAgents[name].mode = mode;
192
- }
217
+ async function triggerHooks(event, sessionID, extraLog, options) {
218
+ const eventHooks = hooks.get(event);
219
+ if (!eventHooks)
220
+ return { blocked: false };
221
+ for (const hook of eventHooks) {
222
+ const result = await executeHookActions(hook, sessionID, extraLog, options);
223
+ if (result.blocked)
224
+ return result;
193
225
  }
194
- if (Object.keys(loadedAgents).length > 0) {
195
- config.agent = { ...loadedAgents, ...(config.agent ?? {}) };
226
+ return { blocked: false };
227
+ }
228
+ async function triggerToolHooks(phase, toolName, sessionID, toolArgs) {
229
+ const canBlock = phase === "before";
230
+ const extraLog = { tool_name: toolName, tool_args: toolArgs };
231
+ const wildcardEvent = `tool.${phase}.*`;
232
+ const wildcardResult = await triggerHooks(wildcardEvent, sessionID, extraLog, { canBlock });
233
+ if (wildcardResult.blocked)
234
+ return wildcardResult;
235
+ const specificEvent = `tool.${phase}.${toolName}`;
236
+ return triggerHooks(specificEvent, sessionID, extraLog, { canBlock });
237
+ }
238
+ function trackModifiedFile(sessionID, toolName, toolArgs) {
239
+ if (!TRACKED_FILE_TOOLS.has(toolName))
240
+ return;
241
+ const filePath = (toolArgs.filePath ?? toolArgs.file_path ?? toolArgs.path);
242
+ if (!filePath)
243
+ return;
244
+ log("[tool.execute.before] File modified", { sessionID, filePath, tool: toolName });
245
+ let files = modifiedCodeFiles.get(sessionID);
246
+ if (!files) {
247
+ files = new Set();
248
+ modifiedCodeFiles.set(sessionID, files);
196
249
  }
197
- if (Object.keys(commands).length > 0) {
198
- config.command = { ...(config.command ?? {}), ...commands };
250
+ files.add(filePath);
251
+ }
252
+ await ctx.agent.transform((editor) => {
253
+ for (const [name, mode] of getPromotedAgents()) {
254
+ if (!editor.get(name))
255
+ continue;
256
+ editor.update(name, (agent) => {
257
+ agent.mode = mode;
258
+ });
199
259
  }
200
- if (skills.length > 0) {
201
- const existingSkills = config.skills ?? {};
202
- const existingPaths = Array.isArray(existingSkills.paths) ? existingSkills.paths : [];
203
- config.skills = {
204
- ...existingSkills,
205
- paths: existingPaths.includes(SKILL_DIR)
206
- ? existingPaths
207
- : [...existingPaths, SKILL_DIR],
208
- };
260
+ });
261
+ await ctx.skill.transform((editor) => {
262
+ for (const skill of skills) {
263
+ editor.add({
264
+ id: skill.name,
265
+ name: skill.name,
266
+ description: skill.description || undefined,
267
+ location: skill.path,
268
+ content: skill.body,
269
+ });
209
270
  }
210
- },
211
- tool: {
212
- gitingest: gitingestTool,
213
- "pdf-to-markdown": pdfToMarkdownTool,
214
- "prompt-session": createPromptSessionTool(ctx.client),
215
- "list-child-sessions": createListChildSessionsTool(ctx.client),
216
- "agent-promote": createAgentPromoteTool(ctx.client, Object.keys(agents)),
217
- "eth-transaction": ethTransactionTool,
218
- "eth-address-txs": ethAddressTxsTool,
219
- "eth-address-balance": ethAddressBalanceTool,
220
- "eth-token-transfers": ethTokenTransfersTool,
221
- },
222
- "tool.execute.before": async (input, output) => {
223
- const sessionID = input.sessionID;
224
- if (!sessionID)
271
+ });
272
+ const promptSessionTool = createPromptSessionTool(ctx.session, tracker);
273
+ const listChildSessionsTool = createListChildSessionsTool(tracker);
274
+ const agentPromoteTool = createAgentPromoteTool(ctx.agent, ctx.agent, ctx.storage, Object.keys(agents));
275
+ await ctx.tool.transform((editor) => {
276
+ editor.add(gitingestTool);
277
+ editor.add(pdfToMarkdownTool);
278
+ editor.add(promptSessionTool);
279
+ editor.add(listChildSessionsTool);
280
+ editor.add(agentPromoteTool);
281
+ editor.add(ethTransactionTool);
282
+ editor.add(ethAddressTxsTool);
283
+ editor.add(ethAddressBalanceTool);
284
+ editor.add(ethTokenTransfersTool);
285
+ });
286
+ await ctx.tool.hook("execute.before", async (event) => {
287
+ if (!event.sessionID)
225
288
  return;
226
- const toolArgs = output.args ?? {};
227
- pendingToolArgs.set(input.callID, toolArgs);
228
- const result = await triggerToolHooks("before", input.tool, sessionID, toolArgs);
289
+ const toolArgs = asRecord(event.input);
290
+ const result = await triggerToolHooks("before", event.tool, event.sessionID, toolArgs);
229
291
  if (result.blocked) {
230
- pendingToolArgs.delete(input.callID);
231
292
  throw new Error(result.blockReason ?? "Blocked by hook");
232
293
  }
233
- if (["write", "edit"].includes(input.tool)) {
234
- const filePath = (toolArgs.filePath ?? toolArgs.file_path ?? toolArgs.path);
235
- if (filePath) {
236
- log("[tool.execute.before] File modified", { sessionID, filePath, tool: input.tool });
237
- let files = modifiedCodeFiles.get(sessionID);
238
- if (!files) {
239
- files = new Set();
240
- modifiedCodeFiles.set(sessionID, files);
241
- }
242
- files.add(filePath);
243
- }
244
- }
245
- },
246
- "tool.execute.after": async (input, _output) => {
247
- const sessionID = input.sessionID;
248
- if (!sessionID)
294
+ trackModifiedFile(event.sessionID, event.tool, toolArgs);
295
+ });
296
+ await ctx.tool.hook("execute.after", async (event) => {
297
+ if (!event.sessionID)
249
298
  return;
250
- const toolArgs = pendingToolArgs.get(input.callID) ?? {};
251
- pendingToolArgs.delete(input.callID);
252
- await triggerToolHooks("after", input.tool, sessionID, toolArgs);
253
- },
254
- event: async ({ event }) => {
255
- const props = event.properties;
256
- if (event.type === "session.created") {
257
- const info = props?.info;
258
- const sessionID = info?.id;
259
- if (!sessionID)
260
- return;
261
- if (!info.parentID) {
262
- log("[event] session.created - main session", { sessionID });
299
+ const toolArgs = asRecord(event.input);
300
+ await triggerToolHooks("after", event.tool, event.sessionID, toolArgs);
301
+ });
302
+ if (skillActivationBlock) {
303
+ await ctx.session.hook("context", (event) => {
304
+ event.system.push({ type: "text", text: skillActivationBlock });
305
+ });
306
+ }
307
+ const controller = new AbortController();
308
+ void (async () => {
309
+ try {
310
+ for await (const event of ctx.event.subscribe({ signal: controller.signal })) {
311
+ const record = event;
312
+ if (record.type === "session.created") {
313
+ const sessionID = readSessionID(event);
314
+ if (!sessionID)
315
+ continue;
316
+ const parentID = readParentID(event);
317
+ if (parentID) {
318
+ tracker.trackChild({
319
+ id: sessionID,
320
+ parentID,
321
+ title: readTitle(event),
322
+ created: Date.now(),
323
+ updated: Date.now(),
324
+ });
325
+ }
326
+ else {
327
+ log("[event] session.created - main session", { sessionID });
328
+ }
329
+ await triggerHooks("session.created", sessionID);
330
+ }
331
+ if (record.type === "session.deleted") {
332
+ const sessionID = readSessionID(event);
333
+ if (!sessionID)
334
+ continue;
335
+ log("[event] session.deleted", { sessionID });
336
+ await triggerHooks("session.deleted", sessionID);
337
+ modifiedCodeFiles.delete(sessionID);
338
+ tracker.removeSession(sessionID);
339
+ }
340
+ if (record.type === "session.idle") {
341
+ const sessionID = readSessionID(event);
342
+ if (!sessionID)
343
+ continue;
344
+ log("[event] session.idle", { sessionID });
345
+ if (!hooks.has("session.idle")) {
346
+ log("[event] session.idle - no hooks defined, skipping");
347
+ continue;
348
+ }
349
+ const files = modifiedCodeFiles.get(sessionID);
350
+ modifiedCodeFiles.delete(sessionID);
351
+ await triggerHooks("session.idle", sessionID, {
352
+ files: files ? Array.from(files) : [],
353
+ });
354
+ }
263
355
  }
264
- await triggerHooks("session.created", sessionID);
265
356
  }
266
- if (event.type === "session.deleted") {
267
- const info = props?.info;
268
- const sessionID = info?.id;
269
- if (!sessionID)
270
- return;
271
- log("[event] session.deleted", { sessionID });
272
- await triggerHooks("session.deleted", sessionID);
273
- modifiedCodeFiles.delete(sessionID);
274
- }
275
- if (event.type === "session.idle") {
276
- const sessionID = props?.sessionID;
277
- if (!sessionID)
278
- return;
279
- log("[event] session.idle", { sessionID });
280
- if (!hooks.has("session.idle")) {
281
- log("[event] session.idle - no hooks defined, skipping");
282
- return;
357
+ catch (error) {
358
+ if (error?.name !== "AbortError") {
359
+ log("[event] subscription ended", { error: String(error) });
283
360
  }
284
- const files = modifiedCodeFiles.get(sessionID);
285
- modifiedCodeFiles.delete(sessionID);
286
- await triggerHooks("session.idle", sessionID, { files: files ? Array.from(files) : [] });
287
- }
288
- },
289
- "experimental.chat.system.transform": async (_input, output) => {
290
- // The activation block relies on OpenCode's native `skill` tool after we
291
- // expose the plugin's bundled skills through `config.skills.paths`.
292
- if (skillActivationBlock) {
293
- output.system.push(skillActivationBlock);
294
361
  }
295
- },
296
- };
297
- };
298
- export default SmartfrogPlugin;
362
+ })();
363
+ return () => controller.abort();
364
+ },
365
+ });
@@ -822,7 +822,7 @@ describe("buildSkillActivationBlock", () => {
822
822
  ];
823
823
  const result = buildSkillActivationBlock(skills);
824
824
  expect(result).toContain("MANDATORY");
825
- expect(result).toContain('skill({ name: "code-review" })');
825
+ expect(result).toContain('skill({ id: "code-review" })');
826
826
  expect(result).toContain("After writing code");
827
827
  });
828
828
  it("should preserve quotes in trigger text", () => {
@@ -871,8 +871,8 @@ describe("buildSkillActivationBlock", () => {
871
871
  { name: "skill-b", description: "B", useWhen: "Trigger B", path: "/b", body: "" },
872
872
  ];
873
873
  const result = buildSkillActivationBlock(skills);
874
- expect(result).toContain('skill({ name: "skill-a" })');
875
- expect(result).toContain('skill({ name: "skill-b" })');
874
+ expect(result).toContain('skill({ id: "skill-a" })');
875
+ expect(result).toContain('skill({ id: "skill-b" })');
876
876
  expect(result).toContain("Trigger A");
877
877
  expect(result).toContain("Trigger B");
878
878
  expect(result.match(/MANDATORY/g)).toHaveLength(2);