banana-code 1.2.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.
- package/LICENSE +21 -0
- package/README.md +246 -0
- package/banana.js +5464 -0
- package/lib/agenticRunner.js +1884 -0
- package/lib/borderRenderer.js +41 -0
- package/lib/commandRunner.js +205 -0
- package/lib/completer.js +286 -0
- package/lib/config.js +301 -0
- package/lib/contextBuilder.js +324 -0
- package/lib/diffViewer.js +295 -0
- package/lib/fileManager.js +224 -0
- package/lib/historyManager.js +124 -0
- package/lib/hookManager.js +1143 -0
- package/lib/imageHandler.js +268 -0
- package/lib/inlineComplete.js +192 -0
- package/lib/interactivePicker.js +254 -0
- package/lib/lmStudio.js +226 -0
- package/lib/markdownRenderer.js +423 -0
- package/lib/mcpClient.js +288 -0
- package/lib/modelRegistry.js +350 -0
- package/lib/monkeyModels.js +97 -0
- package/lib/oauthOpenAI.js +167 -0
- package/lib/parser.js +134 -0
- package/lib/promptManager.js +96 -0
- package/lib/providerClients.js +1014 -0
- package/lib/providerManager.js +130 -0
- package/lib/providerStore.js +413 -0
- package/lib/statusBar.js +283 -0
- package/lib/streamHandler.js +306 -0
- package/lib/subAgentManager.js +406 -0
- package/lib/tokenCounter.js +132 -0
- package/lib/visionAnalyzer.js +163 -0
- package/lib/watcher.js +138 -0
- package/models.json +57 -0
- package/package.json +42 -0
- package/prompts/base.md +23 -0
- package/prompts/code-agent-glm.md +16 -0
- package/prompts/code-agent-gptoss.md +25 -0
- package/prompts/code-agent-nemotron.md +17 -0
- package/prompts/code-agent-qwen.md +20 -0
- package/prompts/code-agent.md +70 -0
- package/prompts/plan.md +44 -0
|
@@ -0,0 +1,1143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook Manager for Banana Code
|
|
3
|
+
* Executes user-defined hooks at key lifecycle points.
|
|
4
|
+
* Supports single-agent hooks, multi-agent A/B hooks, and shell hooks.
|
|
5
|
+
*
|
|
6
|
+
* Hooks are loaded from two locations:
|
|
7
|
+
* - Global: ~/.banana/hooks.json (default, applies to all projects)
|
|
8
|
+
* - Project: <projectDir>/.banana/hooks.json (overrides global by name)
|
|
9
|
+
*
|
|
10
|
+
* Agent hook format (new):
|
|
11
|
+
* { name, enabled, agentA: { model, task }, agentB?: { model, task },
|
|
12
|
+
* maxTurns, readOnly, trigger, inject, timeout }
|
|
13
|
+
*
|
|
14
|
+
* Legacy agent hook format (still supported):
|
|
15
|
+
* { name, enabled, agent, task, readOnly, trigger, inject, timeout }
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
const path = require('path');
|
|
20
|
+
const os = require('os');
|
|
21
|
+
const { spawn } = require('child_process');
|
|
22
|
+
|
|
23
|
+
const DEFAULT_AGENT_TIMEOUT = 60000;
|
|
24
|
+
const DEFAULT_SHELL_TIMEOUT = 30000;
|
|
25
|
+
const DEFAULT_MAX_TURNS = 3;
|
|
26
|
+
const VALID_HOOK_POINTS = ['beforeTurn', 'afterTurn', 'afterWrite', 'afterCommand', 'onError'];
|
|
27
|
+
const VALID_TRIGGERS = ['always', 'fileChanged', 'commandRan', 'hasErrors'];
|
|
28
|
+
const VALID_INJECT_MODES = ['prepend', 'system', 'append'];
|
|
29
|
+
|
|
30
|
+
const REFINE_SYSTEM_PROMPT = `You are an expert at writing clear, actionable instructions for AI coding agents.
|
|
31
|
+
Given a natural language description of what the user wants the agent to do, create a well-structured set of instructions.
|
|
32
|
+
|
|
33
|
+
Rules:
|
|
34
|
+
- State the agent's goal clearly in one sentence
|
|
35
|
+
- List specific steps or checks to perform
|
|
36
|
+
- Specify the expected output format
|
|
37
|
+
- Include constraints or boundaries
|
|
38
|
+
- Be concise but thorough
|
|
39
|
+
|
|
40
|
+
Output ONLY the refined instructions. No preamble, no explanation, no markdown fences.`;
|
|
41
|
+
|
|
42
|
+
const PROMPT_HOOK_SYSTEM = `You are an expert at configuring automated hooks for an AI coding assistant called Banana Code.
|
|
43
|
+
Given a user's natural language description of what they want a hook to do, generate a complete hook configuration.
|
|
44
|
+
|
|
45
|
+
Available hook points:
|
|
46
|
+
- beforeTurn: Runs before each AI request (good for: gathering context, pre-checks)
|
|
47
|
+
- afterTurn: Runs after each AI response (good for: reviews, validation, summaries)
|
|
48
|
+
- afterWrite: Runs after a file is written (good for: linting, formatting, tests)
|
|
49
|
+
- afterCommand: Runs after a shell command (good for: monitoring, logging)
|
|
50
|
+
- onError: Runs when an error occurs (good for: debugging, recovery)
|
|
51
|
+
|
|
52
|
+
Available triggers:
|
|
53
|
+
- always: Fire every time the hook point is reached
|
|
54
|
+
- fileChanged: Only when files were changed
|
|
55
|
+
- commandRan: Only when a command was run
|
|
56
|
+
- hasErrors: Only when an error occurred
|
|
57
|
+
|
|
58
|
+
Respond with ONLY valid JSON (no markdown fences, no explanation) in this exact format:
|
|
59
|
+
{
|
|
60
|
+
"name": "short-kebab-case-name",
|
|
61
|
+
"hookPoint": "afterTurn",
|
|
62
|
+
"trigger": "always",
|
|
63
|
+
"inject": "prepend",
|
|
64
|
+
"readOnly": true,
|
|
65
|
+
"task": "Detailed instructions for what the agent should do..."
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
Pick the most appropriate hookPoint and trigger based on the user's description.
|
|
69
|
+
Set readOnly to true unless the user explicitly wants the hook to modify files.
|
|
70
|
+
The "task" field should be detailed, structured agent instructions (not the user's raw input).
|
|
71
|
+
The inject mode should almost always be "prepend" unless the user specifies otherwise.`;
|
|
72
|
+
|
|
73
|
+
class HookManager {
|
|
74
|
+
constructor(options = {}) {
|
|
75
|
+
this.config = options.config;
|
|
76
|
+
this.subAgentManager = options.subAgentManager;
|
|
77
|
+
this.projectDir = options.projectDir || process.cwd();
|
|
78
|
+
this.globalDir = path.join(os.homedir(), '.banana');
|
|
79
|
+
|
|
80
|
+
// For AI instruction refinement: async (naturalLanguage, modelKey) => refinedText
|
|
81
|
+
this.refineFn = options.refineFn || null;
|
|
82
|
+
|
|
83
|
+
// Callbacks for UI
|
|
84
|
+
this.onHookStart = options.onHookStart || (() => {});
|
|
85
|
+
this.onHookComplete = options.onHookComplete || (() => {});
|
|
86
|
+
this.onHookError = options.onHookError || (() => {});
|
|
87
|
+
this.onHookProgress = options.onHookProgress || (() => {});
|
|
88
|
+
|
|
89
|
+
this._merged = null; // lazy loaded merged view
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
// Config I/O
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
_globalHooksPath() {
|
|
97
|
+
return path.join(this.globalDir, 'hooks.json');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
_projectHooksPath() {
|
|
101
|
+
return path.join(this.projectDir, '.banana', 'hooks.json');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
_readHooksFile(filePath) {
|
|
105
|
+
try {
|
|
106
|
+
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
107
|
+
const parsed = JSON.parse(raw);
|
|
108
|
+
if (typeof parsed === 'object' && parsed !== null) return parsed;
|
|
109
|
+
} catch {
|
|
110
|
+
// file missing or invalid
|
|
111
|
+
}
|
|
112
|
+
return {};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
_writeHooksFile(filePath, hookConfig) {
|
|
116
|
+
const dir = path.dirname(filePath);
|
|
117
|
+
if (!fs.existsSync(dir)) {
|
|
118
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
119
|
+
}
|
|
120
|
+
fs.writeFileSync(filePath, JSON.stringify(hookConfig, null, 2));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Load and merge hooks from global + project.
|
|
125
|
+
* Project hooks with the same name override global hooks (cross-point dedup).
|
|
126
|
+
* Each hook gets a _scope property ('global' or 'project') for display/routing.
|
|
127
|
+
*/
|
|
128
|
+
loadHooks() {
|
|
129
|
+
const globalHooks = this._readHooksFile(this._globalHooksPath());
|
|
130
|
+
const projectHooks = this._readHooksFile(this._projectHooksPath());
|
|
131
|
+
|
|
132
|
+
// Collect all project hook names across all points for cross-point dedup.
|
|
133
|
+
const projectNames = new Set();
|
|
134
|
+
for (const point of VALID_HOOK_POINTS) {
|
|
135
|
+
const list = Array.isArray(projectHooks[point]) ? projectHooks[point] : [];
|
|
136
|
+
for (const h of list) projectNames.add(h.name);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const merged = {};
|
|
140
|
+
|
|
141
|
+
for (const point of VALID_HOOK_POINTS) {
|
|
142
|
+
const globalList = Array.isArray(globalHooks[point]) ? globalHooks[point] : [];
|
|
143
|
+
const projectList = Array.isArray(projectHooks[point]) ? projectHooks[point] : [];
|
|
144
|
+
|
|
145
|
+
const byName = new Map();
|
|
146
|
+
for (const h of globalList) {
|
|
147
|
+
if (!projectNames.has(h.name)) {
|
|
148
|
+
byName.set(h.name, { ...h, _scope: 'global' });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
for (const h of projectList) {
|
|
152
|
+
byName.set(h.name, { ...h, _scope: 'project' });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (byName.size > 0) {
|
|
156
|
+
merged[point] = [...byName.values()];
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
this._merged = merged;
|
|
161
|
+
return merged;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
_getHooks() {
|
|
165
|
+
if (!this._merged) this.loadHooks();
|
|
166
|
+
return this._merged;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
_saveToScope(scope, hookPoint, hookDef) {
|
|
170
|
+
const filePath = scope === 'project' ? this._projectHooksPath() : this._globalHooksPath();
|
|
171
|
+
const existing = this._readHooksFile(filePath);
|
|
172
|
+
if (!existing[hookPoint]) existing[hookPoint] = [];
|
|
173
|
+
|
|
174
|
+
const clean = this._stripRuntime(hookDef);
|
|
175
|
+
|
|
176
|
+
const idx = existing[hookPoint].findIndex(h => h.name === clean.name);
|
|
177
|
+
if (idx >= 0) {
|
|
178
|
+
existing[hookPoint][idx] = clean;
|
|
179
|
+
} else {
|
|
180
|
+
existing[hookPoint].push(clean);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
this._writeHooksFile(filePath, existing);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
_removeFromFile(filePath, name) {
|
|
187
|
+
const existing = this._readHooksFile(filePath);
|
|
188
|
+
let removed = false;
|
|
189
|
+
for (const point of VALID_HOOK_POINTS) {
|
|
190
|
+
if (!existing[point]) continue;
|
|
191
|
+
const before = existing[point].length;
|
|
192
|
+
existing[point] = existing[point].filter(h => h.name !== name);
|
|
193
|
+
if (existing[point].length < before) removed = true;
|
|
194
|
+
if (existing[point].length === 0) delete existing[point];
|
|
195
|
+
}
|
|
196
|
+
if (removed) this._writeHooksFile(filePath, existing);
|
|
197
|
+
return removed;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
_findHookScope(name) {
|
|
201
|
+
const projectHooks = this._readHooksFile(this._projectHooksPath());
|
|
202
|
+
for (const point of VALID_HOOK_POINTS) {
|
|
203
|
+
if (projectHooks[point]?.some(h => h.name === name)) return 'project';
|
|
204
|
+
}
|
|
205
|
+
const globalHooks = this._readHooksFile(this._globalHooksPath());
|
|
206
|
+
for (const point of VALID_HOOK_POINTS) {
|
|
207
|
+
if (globalHooks[point]?.some(h => h.name === name)) return 'global';
|
|
208
|
+
}
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
_findHookPoint(name) {
|
|
213
|
+
const hooks = this._getHooks();
|
|
214
|
+
for (const point of VALID_HOOK_POINTS) {
|
|
215
|
+
if (hooks[point]?.some(h => h.name === name)) return point;
|
|
216
|
+
}
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Strip runtime-only properties before writing to disk. */
|
|
221
|
+
_stripRuntime(hookDef) {
|
|
222
|
+
const clean = { ...hookDef };
|
|
223
|
+
delete clean._scope;
|
|
224
|
+
delete clean.hookPoint;
|
|
225
|
+
return clean;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ---------------------------------------------------------------------------
|
|
229
|
+
// Hook normalization (legacy <-> new format)
|
|
230
|
+
// ---------------------------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Normalize to the new agentA/agentB format for execution.
|
|
234
|
+
* Old: { agent, task } -> { agentA: { model, task }, maxTurns: 1 }
|
|
235
|
+
*/
|
|
236
|
+
_normalize(hookDef) {
|
|
237
|
+
if (hookDef.agentA) return hookDef; // already new format
|
|
238
|
+
if (hookDef.agent && hookDef.task) {
|
|
239
|
+
return {
|
|
240
|
+
...hookDef,
|
|
241
|
+
agentA: { model: hookDef.agent, task: hookDef.task },
|
|
242
|
+
maxTurns: 1
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
return hookDef; // shell hook or invalid
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
// Hook execution
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
async runHooks(hookPoint, context = {}, options = {}) {
|
|
253
|
+
if ((options.depth || 0) > 0) return [];
|
|
254
|
+
|
|
255
|
+
const hooks = this._getHooks();
|
|
256
|
+
const pointHooks = hooks[hookPoint];
|
|
257
|
+
if (!Array.isArray(pointHooks) || pointHooks.length === 0) return [];
|
|
258
|
+
|
|
259
|
+
const results = [];
|
|
260
|
+
|
|
261
|
+
for (const hookDef of pointHooks) {
|
|
262
|
+
if (!hookDef.enabled) continue;
|
|
263
|
+
if (!this.checkTrigger(hookDef, context)) continue;
|
|
264
|
+
|
|
265
|
+
try {
|
|
266
|
+
this.onHookStart(hookDef);
|
|
267
|
+
let result;
|
|
268
|
+
|
|
269
|
+
if (hookDef.agentA || hookDef.agent) {
|
|
270
|
+
result = await this.executeAgentHook(hookDef, context);
|
|
271
|
+
} else if (hookDef.command) {
|
|
272
|
+
result = await this.executeShellHook(hookDef, context);
|
|
273
|
+
} else {
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const formatted = this.formatHookResult(hookDef, result);
|
|
278
|
+
this.onHookComplete(hookDef, formatted);
|
|
279
|
+
results.push({
|
|
280
|
+
name: hookDef.name,
|
|
281
|
+
result: formatted,
|
|
282
|
+
inject: hookDef.inject || 'prepend',
|
|
283
|
+
error: null
|
|
284
|
+
});
|
|
285
|
+
} catch (err) {
|
|
286
|
+
this.onHookError(hookDef, err);
|
|
287
|
+
results.push({
|
|
288
|
+
name: hookDef.name,
|
|
289
|
+
result: null,
|
|
290
|
+
inject: null,
|
|
291
|
+
error: err.message || String(err)
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return results;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async runSingleHook(name, context = {}) {
|
|
300
|
+
const hooks = this._getHooks();
|
|
301
|
+
for (const point of VALID_HOOK_POINTS) {
|
|
302
|
+
if (!hooks[point]) continue;
|
|
303
|
+
const hookDef = hooks[point].find(h => h.name === name);
|
|
304
|
+
if (!hookDef) continue;
|
|
305
|
+
|
|
306
|
+
try {
|
|
307
|
+
this.onHookStart(hookDef);
|
|
308
|
+
let result;
|
|
309
|
+
if (hookDef.agentA || hookDef.agent) {
|
|
310
|
+
result = await this.executeAgentHook(hookDef, context);
|
|
311
|
+
} else if (hookDef.command) {
|
|
312
|
+
result = await this.executeShellHook(hookDef, context);
|
|
313
|
+
} else {
|
|
314
|
+
return { name, result: null, error: 'Invalid hook (no agent or command)' };
|
|
315
|
+
}
|
|
316
|
+
const formatted = this.formatHookResult(hookDef, result);
|
|
317
|
+
this.onHookComplete(hookDef, formatted);
|
|
318
|
+
return { name, result: formatted, inject: hookDef.inject || 'prepend', error: null };
|
|
319
|
+
} catch (err) {
|
|
320
|
+
this.onHookError(hookDef, err);
|
|
321
|
+
return { name, result: null, error: err.message || String(err) };
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Execute an agent hook. Handles both single-agent and multi-agent A/B hooks.
|
|
329
|
+
*/
|
|
330
|
+
async executeAgentHook(hookDef, context) {
|
|
331
|
+
if (!this.subAgentManager) {
|
|
332
|
+
throw new Error('Sub-agent manager not available');
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const normalized = this._normalize(hookDef);
|
|
336
|
+
|
|
337
|
+
if (!normalized.agentB) {
|
|
338
|
+
return this._executeSingleAgent(normalized.agentA, normalized, context);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return this._executeMultiAgent(normalized, context);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async _executeSingleAgent(agentConfig, hookDef, context) {
|
|
345
|
+
const timeout = hookDef.timeout || DEFAULT_AGENT_TIMEOUT;
|
|
346
|
+
let composedTask = agentConfig.task || 'Review the recent changes.';
|
|
347
|
+
composedTask += this._buildContextSuffix(context);
|
|
348
|
+
|
|
349
|
+
const result = await this.subAgentManager.spawn(agentConfig.model, composedTask, {
|
|
350
|
+
readOnly: hookDef.readOnly !== false,
|
|
351
|
+
timeout,
|
|
352
|
+
depth: 1
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
if (result.error) throw new Error(result.error);
|
|
356
|
+
return result.result || '(no output)';
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
async _executeMultiAgent(hookDef, context) {
|
|
360
|
+
// Clamp to at least 2 when Agent B exists so both agents run
|
|
361
|
+
const rawTurns = hookDef.maxTurns || DEFAULT_MAX_TURNS;
|
|
362
|
+
const maxTurns = hookDef.agentB ? Math.max(rawTurns, 2) : rawTurns;
|
|
363
|
+
const timeout = hookDef.timeout || DEFAULT_AGENT_TIMEOUT;
|
|
364
|
+
const contextSuffix = this._buildContextSuffix(context);
|
|
365
|
+
|
|
366
|
+
let lastOutput = '';
|
|
367
|
+
|
|
368
|
+
for (let turn = 0; turn < maxTurns; turn++) {
|
|
369
|
+
const isAgentA = (turn % 2 === 0);
|
|
370
|
+
const agentConfig = isAgentA ? hookDef.agentA : hookDef.agentB;
|
|
371
|
+
if (!agentConfig) break;
|
|
372
|
+
|
|
373
|
+
const agentLabel = isAgentA ? 'A' : 'B';
|
|
374
|
+
this.onHookProgress(hookDef, agentLabel, turn + 1, maxTurns);
|
|
375
|
+
|
|
376
|
+
let task = agentConfig.task;
|
|
377
|
+
if (turn === 0) {
|
|
378
|
+
task += contextSuffix;
|
|
379
|
+
} else {
|
|
380
|
+
task += `\n\n--- Output from Agent ${isAgentA ? 'B' : 'A'} ---\n${lastOutput}`;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const result = await this.subAgentManager.spawn(agentConfig.model, task, {
|
|
384
|
+
readOnly: hookDef.readOnly !== false,
|
|
385
|
+
timeout,
|
|
386
|
+
depth: 1
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
if (result.error) throw new Error(result.error);
|
|
390
|
+
lastOutput = result.result || '(no output)';
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return lastOutput;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
_buildContextSuffix(context) {
|
|
397
|
+
let suffix = '';
|
|
398
|
+
if (context.files && context.files.length > 0) {
|
|
399
|
+
suffix += `\n\nFiles changed: ${context.files.join(', ')}`;
|
|
400
|
+
}
|
|
401
|
+
if (context.response) {
|
|
402
|
+
const preview = context.response.length > 2000
|
|
403
|
+
? context.response.slice(0, 2000) + '\n...(truncated)'
|
|
404
|
+
: context.response;
|
|
405
|
+
suffix += `\n\nRecent AI response:\n${preview}`;
|
|
406
|
+
}
|
|
407
|
+
if (context.error) {
|
|
408
|
+
suffix += `\n\nError that occurred:\n${context.error.message || context.error}`;
|
|
409
|
+
}
|
|
410
|
+
return suffix;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
async executeShellHook(hookDef, context) {
|
|
414
|
+
const timeout = hookDef.timeout || DEFAULT_SHELL_TIMEOUT;
|
|
415
|
+
let command = hookDef.command;
|
|
416
|
+
|
|
417
|
+
if (context.file) {
|
|
418
|
+
command = command.replace(/\{\{file\}\}/g, context.file);
|
|
419
|
+
}
|
|
420
|
+
if (context.files && context.files.length > 0) {
|
|
421
|
+
command = command.replace(/\{\{files\}\}/g, context.files.join(' '));
|
|
422
|
+
if (!context.file) {
|
|
423
|
+
command = command.replace(/\{\{file\}\}/g, context.files[0]);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
command = command.replace(/\{\{projectDir\}\}/g, this.projectDir);
|
|
427
|
+
|
|
428
|
+
return new Promise((resolve, reject) => {
|
|
429
|
+
const isWin = process.platform === 'win32';
|
|
430
|
+
const shell = isWin ? 'cmd.exe' : '/bin/sh';
|
|
431
|
+
const shellArgs = isWin ? ['/c', command] : ['-c', command];
|
|
432
|
+
|
|
433
|
+
let stdout = '';
|
|
434
|
+
let stderr = '';
|
|
435
|
+
|
|
436
|
+
const proc = spawn(shell, shellArgs, {
|
|
437
|
+
cwd: this.projectDir,
|
|
438
|
+
env: { ...process.env }
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
proc.stdout.on('data', (data) => { stdout += data.toString(); });
|
|
442
|
+
proc.stderr.on('data', (data) => { stderr += data.toString(); });
|
|
443
|
+
|
|
444
|
+
const timer = setTimeout(() => {
|
|
445
|
+
proc.kill('SIGTERM');
|
|
446
|
+
reject(new Error(`Shell hook "${hookDef.name}" timed out after ${timeout}ms`));
|
|
447
|
+
}, timeout);
|
|
448
|
+
|
|
449
|
+
proc.on('close', (code) => {
|
|
450
|
+
clearTimeout(timer);
|
|
451
|
+
if (code !== 0) {
|
|
452
|
+
resolve(`Exit code ${code}\n${stderr || stdout}`.trim());
|
|
453
|
+
} else {
|
|
454
|
+
resolve(stdout.trim() || '(no output)');
|
|
455
|
+
}
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
proc.on('error', (err) => {
|
|
459
|
+
clearTimeout(timer);
|
|
460
|
+
reject(err);
|
|
461
|
+
});
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// ---------------------------------------------------------------------------
|
|
466
|
+
// Trigger checking
|
|
467
|
+
// ---------------------------------------------------------------------------
|
|
468
|
+
|
|
469
|
+
checkTrigger(hookDef, context) {
|
|
470
|
+
const trigger = hookDef.trigger || 'always';
|
|
471
|
+
switch (trigger) {
|
|
472
|
+
case 'always': return true;
|
|
473
|
+
case 'fileChanged': return context.files && context.files.length > 0;
|
|
474
|
+
case 'commandRan': return context.commandRan === true;
|
|
475
|
+
case 'hasErrors': return !!context.error;
|
|
476
|
+
default: return true;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// ---------------------------------------------------------------------------
|
|
481
|
+
// Result formatting
|
|
482
|
+
// ---------------------------------------------------------------------------
|
|
483
|
+
|
|
484
|
+
formatHookResult(hookDef, result) {
|
|
485
|
+
return `## Hook: ${hookDef.name}\n\n${result}`;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// ---------------------------------------------------------------------------
|
|
489
|
+
// AI instruction refinement
|
|
490
|
+
// ---------------------------------------------------------------------------
|
|
491
|
+
|
|
492
|
+
async _refineInstructions(naturalLanguage, modelKey) {
|
|
493
|
+
if (!this.refineFn) return naturalLanguage;
|
|
494
|
+
try {
|
|
495
|
+
const refined = await this.refineFn(naturalLanguage, modelKey);
|
|
496
|
+
return refined || naturalLanguage;
|
|
497
|
+
} catch {
|
|
498
|
+
return naturalLanguage;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Interactive prompt for agent instructions with AI refinement.
|
|
504
|
+
* Shows a label, asks for natural language, refines via AI, lets user accept/retry/use raw.
|
|
505
|
+
*/
|
|
506
|
+
async _askForInstructions(label, modelKey, askQuestion) {
|
|
507
|
+
console.log(`\n ${label}`);
|
|
508
|
+
const rawInput = await askQuestion(' Describe what this agent should do: ');
|
|
509
|
+
if (!rawInput || !rawInput.trim()) return null;
|
|
510
|
+
const raw = rawInput.trim();
|
|
511
|
+
|
|
512
|
+
if (!this.refineFn) return raw;
|
|
513
|
+
|
|
514
|
+
console.log(' Refining instructions...');
|
|
515
|
+
const refined = await this._refineInstructions(raw, modelKey);
|
|
516
|
+
|
|
517
|
+
if (refined && refined !== raw) {
|
|
518
|
+
console.log('');
|
|
519
|
+
const lines = refined.split('\n');
|
|
520
|
+
for (const line of lines) {
|
|
521
|
+
console.log(` ${line}`);
|
|
522
|
+
}
|
|
523
|
+
console.log('');
|
|
524
|
+
|
|
525
|
+
const accept = await askQuestion(' (Y)es accept / (R)etry / (U)se my original: ');
|
|
526
|
+
const choice = (accept || 'y').trim().toLowerCase();
|
|
527
|
+
|
|
528
|
+
if (choice.startsWith('r')) {
|
|
529
|
+
return this._askForInstructions(label, modelKey, askQuestion);
|
|
530
|
+
}
|
|
531
|
+
if (choice.startsWith('u')) {
|
|
532
|
+
return raw;
|
|
533
|
+
}
|
|
534
|
+
return refined;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
return raw;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// ---------------------------------------------------------------------------
|
|
541
|
+
// Model picker helper
|
|
542
|
+
// ---------------------------------------------------------------------------
|
|
543
|
+
|
|
544
|
+
_buildModelItems(modelRegistry) {
|
|
545
|
+
if (!modelRegistry) return null;
|
|
546
|
+
const models = modelRegistry.list();
|
|
547
|
+
const items = [];
|
|
548
|
+
for (const m of models) {
|
|
549
|
+
const provider = m.provider || 'local';
|
|
550
|
+
const key = provider === 'local' ? m.key : `${provider}:${m.key}`;
|
|
551
|
+
items.push({
|
|
552
|
+
key,
|
|
553
|
+
label: key,
|
|
554
|
+
description: m.name || '',
|
|
555
|
+
active: m.active
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
return items.length > 0 ? items : null;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
async _pickModel(pickFn, askQuestion, modelRegistry, title) {
|
|
562
|
+
const items = this._buildModelItems(modelRegistry);
|
|
563
|
+
if (items) {
|
|
564
|
+
const choice = await pickFn(items, { title });
|
|
565
|
+
return choice ? choice.key : null;
|
|
566
|
+
}
|
|
567
|
+
const input = await askQuestion(` Model key (e.g. openai:gpt-4o): `);
|
|
568
|
+
return input?.trim() || null;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
_injectItems() {
|
|
572
|
+
return [
|
|
573
|
+
{ key: 'prepend', label: 'Before next turn', description: 'AI sees hook output before your next message (recommended)' },
|
|
574
|
+
{ key: 'system', label: 'Background context', description: 'Added as invisible system context the AI can reference' },
|
|
575
|
+
{ key: 'append', label: 'After next message', description: 'AI sees hook output after your next message' }
|
|
576
|
+
];
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// ---------------------------------------------------------------------------
|
|
580
|
+
// CRUD
|
|
581
|
+
// ---------------------------------------------------------------------------
|
|
582
|
+
|
|
583
|
+
addHook(hookPoint, hookDef, scope = 'global') {
|
|
584
|
+
hookDef.enabled = hookDef.enabled !== false;
|
|
585
|
+
this._saveToScope(scope, hookPoint, hookDef);
|
|
586
|
+
this._merged = null;
|
|
587
|
+
return hookDef;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
removeHook(name) {
|
|
591
|
+
const scope = this._findHookScope(name);
|
|
592
|
+
if (!scope) return false;
|
|
593
|
+
const filePath = scope === 'project' ? this._projectHooksPath() : this._globalHooksPath();
|
|
594
|
+
const removed = this._removeFromFile(filePath, name);
|
|
595
|
+
if (removed) this._merged = null;
|
|
596
|
+
return removed;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
toggleHook(name) {
|
|
600
|
+
const scope = this._findHookScope(name);
|
|
601
|
+
if (!scope) return null;
|
|
602
|
+
|
|
603
|
+
const filePath = scope === 'project' ? this._projectHooksPath() : this._globalHooksPath();
|
|
604
|
+
const existing = this._readHooksFile(filePath);
|
|
605
|
+
|
|
606
|
+
for (const point of VALID_HOOK_POINTS) {
|
|
607
|
+
if (!existing[point]) continue;
|
|
608
|
+
for (const h of existing[point]) {
|
|
609
|
+
if (h.name === name) {
|
|
610
|
+
h.enabled = !h.enabled;
|
|
611
|
+
delete h._scope;
|
|
612
|
+
this._writeHooksFile(filePath, existing);
|
|
613
|
+
this._merged = null;
|
|
614
|
+
return h.enabled;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
return null;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
updateHook(name, updates) {
|
|
622
|
+
const scope = this._findHookScope(name);
|
|
623
|
+
if (!scope) return null;
|
|
624
|
+
|
|
625
|
+
const filePath = scope === 'project' ? this._projectHooksPath() : this._globalHooksPath();
|
|
626
|
+
const existing = this._readHooksFile(filePath);
|
|
627
|
+
|
|
628
|
+
for (const point of VALID_HOOK_POINTS) {
|
|
629
|
+
if (!existing[point]) continue;
|
|
630
|
+
for (const h of existing[point]) {
|
|
631
|
+
if (h.name === name) {
|
|
632
|
+
Object.assign(h, updates);
|
|
633
|
+
delete h._scope;
|
|
634
|
+
this._writeHooksFile(filePath, existing);
|
|
635
|
+
this._merged = null;
|
|
636
|
+
return h;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
return null;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
listAll() {
|
|
644
|
+
const hooks = this._getHooks();
|
|
645
|
+
const all = [];
|
|
646
|
+
for (const point of VALID_HOOK_POINTS) {
|
|
647
|
+
if (!hooks[point]) continue;
|
|
648
|
+
for (const h of hooks[point]) {
|
|
649
|
+
all.push({ ...h, hookPoint: point });
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
return all;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// ---------------------------------------------------------------------------
|
|
656
|
+
// Add wizard
|
|
657
|
+
// ---------------------------------------------------------------------------
|
|
658
|
+
|
|
659
|
+
async runAddWizard(pickFn, askQuestion, modelRegistry) {
|
|
660
|
+
// 1. Scope
|
|
661
|
+
const scopeItems = [
|
|
662
|
+
{ key: 'global', label: 'Global', description: 'Applies to all projects (~/.banana/hooks.json)' },
|
|
663
|
+
{ key: 'project', label: 'Project', description: 'Only this project (.banana/hooks.json)' }
|
|
664
|
+
];
|
|
665
|
+
const scopeChoice = await pickFn(scopeItems, { title: 'Hook Scope' });
|
|
666
|
+
if (!scopeChoice) return null;
|
|
667
|
+
const scope = scopeChoice.key;
|
|
668
|
+
|
|
669
|
+
// 2. Hook point
|
|
670
|
+
const pointItems = VALID_HOOK_POINTS.map(p => ({
|
|
671
|
+
key: p,
|
|
672
|
+
label: p,
|
|
673
|
+
description: {
|
|
674
|
+
beforeTurn: 'Runs before each AI request',
|
|
675
|
+
afterTurn: 'Runs after each AI response',
|
|
676
|
+
afterWrite: 'Runs after a file is written',
|
|
677
|
+
afterCommand: 'Runs after a shell command',
|
|
678
|
+
onError: 'Runs when an error occurs'
|
|
679
|
+
}[p] || ''
|
|
680
|
+
}));
|
|
681
|
+
const pointChoice = await pickFn(pointItems, { title: 'Hook Point' });
|
|
682
|
+
if (!pointChoice) return null;
|
|
683
|
+
|
|
684
|
+
// 3. Hook type
|
|
685
|
+
const typeItems = [
|
|
686
|
+
{ key: 'prompt', label: 'Prompt', description: 'Describe what you want - AI configures everything' },
|
|
687
|
+
{ key: 'agent', label: 'Agent Hook', description: 'Manually configure agent(s) with instructions' },
|
|
688
|
+
{ key: 'shell', label: 'Shell Hook', description: 'Run a shell command' }
|
|
689
|
+
];
|
|
690
|
+
const typeChoice = await pickFn(typeItems, { title: 'Hook Type' });
|
|
691
|
+
if (!typeChoice) return null;
|
|
692
|
+
|
|
693
|
+
// --- PROMPT PATH: AI generates the full hook from a description ---
|
|
694
|
+
if (typeChoice.key === 'prompt') {
|
|
695
|
+
return this._runPromptWizard(pickFn, askQuestion, modelRegistry, scope, pointChoice.key);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// 4. Name
|
|
699
|
+
const name = await askQuestion(' Hook name: ');
|
|
700
|
+
if (!name || !name.trim()) return null;
|
|
701
|
+
const hookName = name.trim();
|
|
702
|
+
|
|
703
|
+
const hookDef = { name: hookName, enabled: true };
|
|
704
|
+
|
|
705
|
+
if (typeChoice.key === 'agent') {
|
|
706
|
+
// --- AGENT A ---
|
|
707
|
+
console.log(`\n --- AGENT A ---`);
|
|
708
|
+
|
|
709
|
+
const modelA = await this._pickModel(pickFn, askQuestion, modelRegistry, 'Agent A Model');
|
|
710
|
+
if (!modelA) return null;
|
|
711
|
+
|
|
712
|
+
const taskA = await this._askForInstructions(
|
|
713
|
+
'INSTRUCTIONS FOR AGENT A',
|
|
714
|
+
modelA,
|
|
715
|
+
askQuestion
|
|
716
|
+
);
|
|
717
|
+
if (!taskA) return null;
|
|
718
|
+
|
|
719
|
+
hookDef.agentA = { model: modelA, task: taskA };
|
|
720
|
+
|
|
721
|
+
// --- AGENT B (optional) ---
|
|
722
|
+
const addBItems = [
|
|
723
|
+
{ key: 'yes', label: 'Yes', description: 'Add a second agent that processes Agent A output' },
|
|
724
|
+
{ key: 'no', label: 'No', description: 'Single agent hook' }
|
|
725
|
+
];
|
|
726
|
+
const addBChoice = await pickFn(addBItems, { title: 'Add Agent B?' });
|
|
727
|
+
|
|
728
|
+
if (addBChoice && addBChoice.key === 'yes') {
|
|
729
|
+
console.log(`\n --- AGENT B ---`);
|
|
730
|
+
|
|
731
|
+
const modelB = await this._pickModel(pickFn, askQuestion, modelRegistry, 'Agent B Model');
|
|
732
|
+
if (!modelB) return null;
|
|
733
|
+
|
|
734
|
+
const taskB = await this._askForInstructions(
|
|
735
|
+
'INSTRUCTIONS FOR AGENT B',
|
|
736
|
+
modelB,
|
|
737
|
+
askQuestion
|
|
738
|
+
);
|
|
739
|
+
if (!taskB) return null;
|
|
740
|
+
|
|
741
|
+
hookDef.agentB = { model: modelB, task: taskB };
|
|
742
|
+
|
|
743
|
+
// Max turns
|
|
744
|
+
const maxInput = await askQuestion(` Max turns (default ${DEFAULT_MAX_TURNS}): `);
|
|
745
|
+
const parsed = parseInt(maxInput, 10);
|
|
746
|
+
hookDef.maxTurns = (parsed > 0) ? parsed : DEFAULT_MAX_TURNS;
|
|
747
|
+
} else {
|
|
748
|
+
hookDef.maxTurns = 1;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// Read-only?
|
|
752
|
+
const readOnlyInput = await askQuestion(' Read-only agents? (Y/n): ');
|
|
753
|
+
hookDef.readOnly = !readOnlyInput.trim() || readOnlyInput.trim().toLowerCase().startsWith('y');
|
|
754
|
+
|
|
755
|
+
// Where does the hook's output go?
|
|
756
|
+
const injectChoice = await pickFn(this._injectItems(), { title: 'Where Should Hook Output Go?' });
|
|
757
|
+
hookDef.inject = injectChoice ? injectChoice.key : 'prepend';
|
|
758
|
+
|
|
759
|
+
} else {
|
|
760
|
+
// Shell hook
|
|
761
|
+
console.log(' Available templates: {{file}}, {{files}}, {{projectDir}}');
|
|
762
|
+
const command = await askQuestion(' Command: ');
|
|
763
|
+
if (!command || !command.trim()) return null;
|
|
764
|
+
hookDef.command = command.trim();
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// Trigger
|
|
768
|
+
const triggerItems = VALID_TRIGGERS.map(t => ({
|
|
769
|
+
key: t,
|
|
770
|
+
label: t,
|
|
771
|
+
description: {
|
|
772
|
+
always: 'Always fire',
|
|
773
|
+
fileChanged: 'Only when files were changed',
|
|
774
|
+
commandRan: 'Only when a command was run',
|
|
775
|
+
hasErrors: 'Only when an error occurred'
|
|
776
|
+
}[t] || ''
|
|
777
|
+
}));
|
|
778
|
+
const triggerChoice = await pickFn(triggerItems, { title: 'Trigger' });
|
|
779
|
+
hookDef.trigger = triggerChoice ? triggerChoice.key : 'always';
|
|
780
|
+
|
|
781
|
+
// Timeout
|
|
782
|
+
const defaultTimeout = typeChoice.key === 'agent' ? DEFAULT_AGENT_TIMEOUT : DEFAULT_SHELL_TIMEOUT;
|
|
783
|
+
const timeoutInput = await askQuestion(` Timeout in ms (default ${defaultTimeout}): `);
|
|
784
|
+
const parsedTimeout = parseInt(timeoutInput, 10);
|
|
785
|
+
if (parsedTimeout > 0) {
|
|
786
|
+
hookDef.timeout = parsedTimeout;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
// Save
|
|
790
|
+
this.addHook(pointChoice.key, hookDef, scope);
|
|
791
|
+
return { hookPoint: pointChoice.key, hookDef, scope };
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// ---------------------------------------------------------------------------
|
|
795
|
+
// Prompt wizard (AI-generated hook from natural language)
|
|
796
|
+
// ---------------------------------------------------------------------------
|
|
797
|
+
|
|
798
|
+
async _runPromptWizard(pickFn, askQuestion, modelRegistry, scope, suggestedHookPoint) {
|
|
799
|
+
if (!this.refineFn) {
|
|
800
|
+
console.log(' AI refinement not available. Use Agent Hook instead.');
|
|
801
|
+
return null;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// Pick model for the hook agent (will also be used for generation)
|
|
805
|
+
const model = await this._pickModel(pickFn, askQuestion, modelRegistry, 'Model for this Hook');
|
|
806
|
+
if (!model) return null;
|
|
807
|
+
|
|
808
|
+
console.log('\n Describe what this hook should do (plain English):');
|
|
809
|
+
const description = await askQuestion(' > ');
|
|
810
|
+
if (!description?.trim()) return null;
|
|
811
|
+
|
|
812
|
+
console.log(' Generating hook configuration...');
|
|
813
|
+
|
|
814
|
+
let generated;
|
|
815
|
+
try {
|
|
816
|
+
const raw = await this.refineFn(
|
|
817
|
+
`Hook point the user already selected: ${suggestedHookPoint}\n\nUser request: ${description.trim()}`,
|
|
818
|
+
model,
|
|
819
|
+
PROMPT_HOOK_SYSTEM
|
|
820
|
+
);
|
|
821
|
+
generated = JSON.parse(raw);
|
|
822
|
+
} catch {
|
|
823
|
+
console.log(' Could not parse AI response. Falling back to defaults.');
|
|
824
|
+
generated = null;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// Build hook from AI output or defaults
|
|
828
|
+
const hookPoint = generated?.hookPoint && VALID_HOOK_POINTS.includes(generated.hookPoint)
|
|
829
|
+
? generated.hookPoint : suggestedHookPoint;
|
|
830
|
+
const hookName = generated?.name || description.trim().slice(0, 30).replace(/\s+/g, '-').toLowerCase();
|
|
831
|
+
const task = generated?.task || description.trim();
|
|
832
|
+
const trigger = generated?.trigger && VALID_TRIGGERS.includes(generated.trigger)
|
|
833
|
+
? generated.trigger : 'always';
|
|
834
|
+
const inject = generated?.inject && VALID_INJECT_MODES.includes(generated.inject)
|
|
835
|
+
? generated.inject : 'prepend';
|
|
836
|
+
const readOnly = generated?.readOnly !== false;
|
|
837
|
+
|
|
838
|
+
// Show generated config
|
|
839
|
+
console.log('');
|
|
840
|
+
console.log(` Name: ${hookName}`);
|
|
841
|
+
console.log(` Hook Point: ${hookPoint}`);
|
|
842
|
+
console.log(` Model: ${model}`);
|
|
843
|
+
console.log(` Trigger: ${trigger}`);
|
|
844
|
+
console.log(` Inject: ${inject}`);
|
|
845
|
+
console.log(` Read-only: ${readOnly ? 'Yes' : 'No'}`);
|
|
846
|
+
console.log(` Task:`);
|
|
847
|
+
const taskLines = task.split('\n');
|
|
848
|
+
for (const line of taskLines.slice(0, 15)) {
|
|
849
|
+
console.log(` ${line}`);
|
|
850
|
+
}
|
|
851
|
+
if (taskLines.length > 15) console.log(` ...(${taskLines.length - 15} more lines)`);
|
|
852
|
+
console.log('');
|
|
853
|
+
|
|
854
|
+
const confirm = await askQuestion(' Create this hook? (Y/n): ');
|
|
855
|
+
if (confirm?.trim().toLowerCase().startsWith('n')) {
|
|
856
|
+
return null;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
const hookDef = {
|
|
860
|
+
name: hookName,
|
|
861
|
+
enabled: true,
|
|
862
|
+
agentA: { model, task },
|
|
863
|
+
maxTurns: 1,
|
|
864
|
+
readOnly,
|
|
865
|
+
trigger,
|
|
866
|
+
inject
|
|
867
|
+
};
|
|
868
|
+
|
|
869
|
+
this.addHook(hookPoint, hookDef, scope);
|
|
870
|
+
return { hookPoint, hookDef, scope };
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
// ---------------------------------------------------------------------------
|
|
874
|
+
// Edit wizard
|
|
875
|
+
// ---------------------------------------------------------------------------
|
|
876
|
+
|
|
877
|
+
async runEditWizard(pickFn, askQuestion, modelRegistry, hookName) {
|
|
878
|
+
// If no name given, let user pick from list
|
|
879
|
+
if (!hookName) {
|
|
880
|
+
const allHooks = this.listAll();
|
|
881
|
+
if (allHooks.length === 0) {
|
|
882
|
+
console.log(' No hooks to edit.');
|
|
883
|
+
return null;
|
|
884
|
+
}
|
|
885
|
+
const hookItems = allHooks.map(h => {
|
|
886
|
+
const type = h.agentA ? 'agent A/B' : h.agent ? 'agent' : 'shell';
|
|
887
|
+
return {
|
|
888
|
+
key: h.name,
|
|
889
|
+
label: h.name,
|
|
890
|
+
description: `${h.hookPoint}, ${h._scope}, ${type}`
|
|
891
|
+
};
|
|
892
|
+
});
|
|
893
|
+
const picked = await pickFn(hookItems, { title: 'Select Hook to Edit' });
|
|
894
|
+
if (!picked) return null;
|
|
895
|
+
hookName = picked.key;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// Find the hook
|
|
899
|
+
const allHooks = this.listAll();
|
|
900
|
+
const hook = allHooks.find(h => h.name === hookName);
|
|
901
|
+
if (!hook) {
|
|
902
|
+
console.log(` Hook "${hookName}" not found.`);
|
|
903
|
+
return null;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
const normalized = this._normalize(hook);
|
|
907
|
+
const isAgent = !!(normalized.agentA || hook.agent);
|
|
908
|
+
const isMultiAgent = !!normalized.agentB;
|
|
909
|
+
|
|
910
|
+
// Show current config
|
|
911
|
+
console.log('');
|
|
912
|
+
console.log(` Current configuration for "${hookName}":`);
|
|
913
|
+
console.log(` Scope: ${hook._scope || 'global'}`);
|
|
914
|
+
console.log(` Hook Point: ${hook.hookPoint}`);
|
|
915
|
+
if (isAgent) {
|
|
916
|
+
const aModel = normalized.agentA?.model || hook.agent;
|
|
917
|
+
const aTask = normalized.agentA?.task || hook.task;
|
|
918
|
+
console.log(` Agent A: ${aModel}`);
|
|
919
|
+
console.log(` Task: ${aTask?.slice(0, 80)}${(aTask?.length || 0) > 80 ? '...' : ''}`);
|
|
920
|
+
if (isMultiAgent) {
|
|
921
|
+
console.log(` Agent B: ${normalized.agentB.model}`);
|
|
922
|
+
console.log(` Task: ${normalized.agentB.task?.slice(0, 80)}${(normalized.agentB.task?.length || 0) > 80 ? '...' : ''}`);
|
|
923
|
+
console.log(` Max Turns: ${normalized.maxTurns || DEFAULT_MAX_TURNS}`);
|
|
924
|
+
}
|
|
925
|
+
console.log(` Read-only: ${hook.readOnly !== false ? 'Yes' : 'No'}`);
|
|
926
|
+
console.log(` Inject: ${hook.inject || 'prepend'}`);
|
|
927
|
+
} else {
|
|
928
|
+
console.log(` Command: ${hook.command}`);
|
|
929
|
+
}
|
|
930
|
+
console.log(` Trigger: ${hook.trigger || 'always'}`);
|
|
931
|
+
console.log(` Timeout: ${hook.timeout || (isAgent ? DEFAULT_AGENT_TIMEOUT : DEFAULT_SHELL_TIMEOUT)}ms`);
|
|
932
|
+
console.log('');
|
|
933
|
+
|
|
934
|
+
// Build editable fields
|
|
935
|
+
const fields = [];
|
|
936
|
+
if (isAgent) {
|
|
937
|
+
fields.push({ key: 'agentA.model', label: 'Agent A Model' });
|
|
938
|
+
fields.push({ key: 'agentA.task', label: 'Agent A Instructions' });
|
|
939
|
+
if (isMultiAgent) {
|
|
940
|
+
fields.push({ key: 'agentB.model', label: 'Agent B Model' });
|
|
941
|
+
fields.push({ key: 'agentB.task', label: 'Agent B Instructions' });
|
|
942
|
+
fields.push({ key: 'maxTurns', label: 'Max Turns' });
|
|
943
|
+
} else {
|
|
944
|
+
fields.push({ key: 'addAgentB', label: 'Add Agent B' });
|
|
945
|
+
}
|
|
946
|
+
if (isMultiAgent) {
|
|
947
|
+
fields.push({ key: 'removeAgentB', label: 'Remove Agent B' });
|
|
948
|
+
}
|
|
949
|
+
fields.push({ key: 'readOnly', label: 'Read-only' });
|
|
950
|
+
fields.push({ key: 'inject', label: 'Inject Mode' });
|
|
951
|
+
} else {
|
|
952
|
+
fields.push({ key: 'command', label: 'Command' });
|
|
953
|
+
}
|
|
954
|
+
fields.push({ key: 'trigger', label: 'Trigger' });
|
|
955
|
+
fields.push({ key: 'timeout', label: 'Timeout' });
|
|
956
|
+
fields.push({ key: 'done', label: 'Save & Exit' });
|
|
957
|
+
|
|
958
|
+
const updates = {};
|
|
959
|
+
let editing = true;
|
|
960
|
+
|
|
961
|
+
while (editing) {
|
|
962
|
+
const fieldChoice = await pickFn(
|
|
963
|
+
fields.map(f => ({ key: f.key, label: f.label, description: '' })),
|
|
964
|
+
{ title: 'Edit Field' }
|
|
965
|
+
);
|
|
966
|
+
if (!fieldChoice || fieldChoice.key === 'done') {
|
|
967
|
+
editing = false;
|
|
968
|
+
break;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
switch (fieldChoice.key) {
|
|
972
|
+
case 'agentA.model': {
|
|
973
|
+
const model = await this._pickModel(pickFn, askQuestion, modelRegistry, 'Agent A Model');
|
|
974
|
+
if (model) {
|
|
975
|
+
if (!updates.agentA) updates.agentA = { ...(normalized.agentA || {}) };
|
|
976
|
+
updates.agentA.model = model;
|
|
977
|
+
// Also clear legacy fields if present
|
|
978
|
+
updates.agent = undefined;
|
|
979
|
+
}
|
|
980
|
+
break;
|
|
981
|
+
}
|
|
982
|
+
case 'agentA.task': {
|
|
983
|
+
const modelKey = updates.agentA?.model || normalized.agentA?.model || hook.agent;
|
|
984
|
+
const task = await this._askForInstructions('INSTRUCTIONS FOR AGENT A', modelKey, askQuestion);
|
|
985
|
+
if (task) {
|
|
986
|
+
if (!updates.agentA) updates.agentA = { ...(normalized.agentA || {}) };
|
|
987
|
+
updates.agentA.task = task;
|
|
988
|
+
updates.task = undefined;
|
|
989
|
+
}
|
|
990
|
+
break;
|
|
991
|
+
}
|
|
992
|
+
case 'agentB.model': {
|
|
993
|
+
const model = await this._pickModel(pickFn, askQuestion, modelRegistry, 'Agent B Model');
|
|
994
|
+
if (model) {
|
|
995
|
+
if (!updates.agentB) updates.agentB = { ...(normalized.agentB || {}) };
|
|
996
|
+
updates.agentB.model = model;
|
|
997
|
+
}
|
|
998
|
+
break;
|
|
999
|
+
}
|
|
1000
|
+
case 'agentB.task': {
|
|
1001
|
+
const modelKey = updates.agentB?.model || normalized.agentB?.model;
|
|
1002
|
+
const task = await this._askForInstructions('INSTRUCTIONS FOR AGENT B', modelKey, askQuestion);
|
|
1003
|
+
if (task) {
|
|
1004
|
+
if (!updates.agentB) updates.agentB = { ...(normalized.agentB || {}) };
|
|
1005
|
+
updates.agentB.task = task;
|
|
1006
|
+
}
|
|
1007
|
+
break;
|
|
1008
|
+
}
|
|
1009
|
+
case 'addAgentB': {
|
|
1010
|
+
console.log(`\n --- AGENT B ---`);
|
|
1011
|
+
const model = await this._pickModel(pickFn, askQuestion, modelRegistry, 'Agent B Model');
|
|
1012
|
+
if (!model) break;
|
|
1013
|
+
const task = await this._askForInstructions('INSTRUCTIONS FOR AGENT B', model, askQuestion);
|
|
1014
|
+
if (!task) break;
|
|
1015
|
+
updates.agentB = { model, task };
|
|
1016
|
+
const maxInput = await askQuestion(` Max turns (default ${DEFAULT_MAX_TURNS}): `);
|
|
1017
|
+
const parsed = parseInt(maxInput, 10);
|
|
1018
|
+
updates.maxTurns = (parsed > 0) ? parsed : DEFAULT_MAX_TURNS;
|
|
1019
|
+
// Update field list to reflect the change
|
|
1020
|
+
const addIdx = fields.findIndex(f => f.key === 'addAgentB');
|
|
1021
|
+
if (addIdx >= 0) {
|
|
1022
|
+
fields.splice(addIdx, 1,
|
|
1023
|
+
{ key: 'agentB.model', label: 'Agent B Model' },
|
|
1024
|
+
{ key: 'agentB.task', label: 'Agent B Instructions' },
|
|
1025
|
+
{ key: 'maxTurns', label: 'Max Turns' },
|
|
1026
|
+
{ key: 'removeAgentB', label: 'Remove Agent B' }
|
|
1027
|
+
);
|
|
1028
|
+
}
|
|
1029
|
+
break;
|
|
1030
|
+
}
|
|
1031
|
+
case 'removeAgentB': {
|
|
1032
|
+
updates.agentB = null;
|
|
1033
|
+
updates.maxTurns = 1;
|
|
1034
|
+
// Update field list
|
|
1035
|
+
const bModelIdx = fields.findIndex(f => f.key === 'agentB.model');
|
|
1036
|
+
const removeIdx = fields.findIndex(f => f.key === 'removeAgentB');
|
|
1037
|
+
if (bModelIdx >= 0 && removeIdx >= 0) {
|
|
1038
|
+
fields.splice(bModelIdx, removeIdx - bModelIdx + 1,
|
|
1039
|
+
{ key: 'addAgentB', label: 'Add Agent B' }
|
|
1040
|
+
);
|
|
1041
|
+
}
|
|
1042
|
+
console.log(' Agent B removed.');
|
|
1043
|
+
break;
|
|
1044
|
+
}
|
|
1045
|
+
case 'maxTurns': {
|
|
1046
|
+
const current = updates.maxTurns || normalized.maxTurns || DEFAULT_MAX_TURNS;
|
|
1047
|
+
const input = await askQuestion(` Max turns (current: ${current}): `);
|
|
1048
|
+
const parsed = parseInt(input, 10);
|
|
1049
|
+
if (parsed > 0) updates.maxTurns = parsed;
|
|
1050
|
+
break;
|
|
1051
|
+
}
|
|
1052
|
+
case 'readOnly': {
|
|
1053
|
+
const current = hook.readOnly !== false;
|
|
1054
|
+
const input = await askQuestion(` Read-only? (current: ${current ? 'Yes' : 'No'}) (Y/n): `);
|
|
1055
|
+
if (input?.trim()) {
|
|
1056
|
+
updates.readOnly = input.trim().toLowerCase().startsWith('y');
|
|
1057
|
+
}
|
|
1058
|
+
break;
|
|
1059
|
+
}
|
|
1060
|
+
case 'inject': {
|
|
1061
|
+
const choice = await pickFn(this._injectItems(), { title: 'Where Should Hook Output Go?' });
|
|
1062
|
+
if (choice) updates.inject = choice.key;
|
|
1063
|
+
break;
|
|
1064
|
+
}
|
|
1065
|
+
case 'command': {
|
|
1066
|
+
console.log(' Available templates: {{file}}, {{files}}, {{projectDir}}');
|
|
1067
|
+
const input = await askQuestion(` Command (current: ${hook.command}): `);
|
|
1068
|
+
if (input?.trim()) updates.command = input.trim();
|
|
1069
|
+
break;
|
|
1070
|
+
}
|
|
1071
|
+
case 'trigger': {
|
|
1072
|
+
const triggerItems = VALID_TRIGGERS.map(t => ({
|
|
1073
|
+
key: t, label: t, description: ''
|
|
1074
|
+
}));
|
|
1075
|
+
const choice = await pickFn(triggerItems, { title: 'Trigger' });
|
|
1076
|
+
if (choice) updates.trigger = choice.key;
|
|
1077
|
+
break;
|
|
1078
|
+
}
|
|
1079
|
+
case 'timeout': {
|
|
1080
|
+
const current = hook.timeout || (isAgent ? DEFAULT_AGENT_TIMEOUT : DEFAULT_SHELL_TIMEOUT);
|
|
1081
|
+
const input = await askQuestion(` Timeout in ms (current: ${current}): `);
|
|
1082
|
+
const parsed = parseInt(input, 10);
|
|
1083
|
+
if (parsed > 0) updates.timeout = parsed;
|
|
1084
|
+
break;
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
// Apply updates
|
|
1090
|
+
if (Object.keys(updates).length === 0) return null;
|
|
1091
|
+
|
|
1092
|
+
// If migrating from legacy format, ensure old fields get removed
|
|
1093
|
+
if (hook.agent && (updates.agentA || updates.agentB)) {
|
|
1094
|
+
if (!updates.agentA) {
|
|
1095
|
+
updates.agentA = { model: hook.agent, task: hook.task };
|
|
1096
|
+
}
|
|
1097
|
+
if (updates.agent === undefined) updates.agent = null; // mark for deletion
|
|
1098
|
+
else if (!('agent' in updates)) updates.agent = null;
|
|
1099
|
+
if (updates.task === undefined) updates.task = null;
|
|
1100
|
+
else if (!('task' in updates)) updates.task = null;
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
const scope = this._findHookScope(hookName);
|
|
1104
|
+
const hookPoint = this._findHookPoint(hookName);
|
|
1105
|
+
if (scope && hookPoint) {
|
|
1106
|
+
const filePath = scope === 'project' ? this._projectHooksPath() : this._globalHooksPath();
|
|
1107
|
+
const existing = this._readHooksFile(filePath);
|
|
1108
|
+
|
|
1109
|
+
if (existing[hookPoint]) {
|
|
1110
|
+
for (const h of existing[hookPoint]) {
|
|
1111
|
+
if (h.name === hookName) {
|
|
1112
|
+
for (const [k, v] of Object.entries(updates)) {
|
|
1113
|
+
if (v === undefined || v === null) {
|
|
1114
|
+
delete h[k];
|
|
1115
|
+
} else {
|
|
1116
|
+
h[k] = v;
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
delete h._scope;
|
|
1120
|
+
delete h.hookPoint;
|
|
1121
|
+
break;
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
this._writeHooksFile(filePath, existing);
|
|
1125
|
+
this._merged = null;
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
return { hookName, updates };
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
module.exports = {
|
|
1134
|
+
HookManager,
|
|
1135
|
+
VALID_HOOK_POINTS,
|
|
1136
|
+
VALID_TRIGGERS,
|
|
1137
|
+
VALID_INJECT_MODES,
|
|
1138
|
+
DEFAULT_AGENT_TIMEOUT,
|
|
1139
|
+
DEFAULT_SHELL_TIMEOUT,
|
|
1140
|
+
DEFAULT_MAX_TURNS,
|
|
1141
|
+
REFINE_SYSTEM_PROMPT,
|
|
1142
|
+
PROMPT_HOOK_SYSTEM
|
|
1143
|
+
};
|