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.
Files changed (42) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +246 -0
  3. package/banana.js +5464 -0
  4. package/lib/agenticRunner.js +1884 -0
  5. package/lib/borderRenderer.js +41 -0
  6. package/lib/commandRunner.js +205 -0
  7. package/lib/completer.js +286 -0
  8. package/lib/config.js +301 -0
  9. package/lib/contextBuilder.js +324 -0
  10. package/lib/diffViewer.js +295 -0
  11. package/lib/fileManager.js +224 -0
  12. package/lib/historyManager.js +124 -0
  13. package/lib/hookManager.js +1143 -0
  14. package/lib/imageHandler.js +268 -0
  15. package/lib/inlineComplete.js +192 -0
  16. package/lib/interactivePicker.js +254 -0
  17. package/lib/lmStudio.js +226 -0
  18. package/lib/markdownRenderer.js +423 -0
  19. package/lib/mcpClient.js +288 -0
  20. package/lib/modelRegistry.js +350 -0
  21. package/lib/monkeyModels.js +97 -0
  22. package/lib/oauthOpenAI.js +167 -0
  23. package/lib/parser.js +134 -0
  24. package/lib/promptManager.js +96 -0
  25. package/lib/providerClients.js +1014 -0
  26. package/lib/providerManager.js +130 -0
  27. package/lib/providerStore.js +413 -0
  28. package/lib/statusBar.js +283 -0
  29. package/lib/streamHandler.js +306 -0
  30. package/lib/subAgentManager.js +406 -0
  31. package/lib/tokenCounter.js +132 -0
  32. package/lib/visionAnalyzer.js +163 -0
  33. package/lib/watcher.js +138 -0
  34. package/models.json +57 -0
  35. package/package.json +42 -0
  36. package/prompts/base.md +23 -0
  37. package/prompts/code-agent-glm.md +16 -0
  38. package/prompts/code-agent-gptoss.md +25 -0
  39. package/prompts/code-agent-nemotron.md +17 -0
  40. package/prompts/code-agent-qwen.md +20 -0
  41. package/prompts/code-agent.md +70 -0
  42. 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
+ };