@corbat-tech/coco 2.5.1 → 2.5.3

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/dist/cli/index.js CHANGED
@@ -12,8 +12,8 @@ import fs32__default, { mkdir, writeFile, readFile, access, readdir, rm } from '
12
12
  import JSON5 from 'json5';
13
13
  import { Logger } from 'tslog';
14
14
  import Anthropic from '@anthropic-ai/sdk';
15
- import OpenAI from 'openai';
16
15
  import { jsonrepair } from 'jsonrepair';
16
+ import OpenAI from 'openai';
17
17
  import * as crypto from 'crypto';
18
18
  import { randomUUID } from 'crypto';
19
19
  import * as http from 'http';
@@ -1050,6 +1050,20 @@ var init_anthropic = __esm({
1050
1050
  if (event.type === "content_block_start") {
1051
1051
  const contentBlock = event.content_block;
1052
1052
  if (contentBlock.type === "tool_use") {
1053
+ if (currentToolCall) {
1054
+ getLogger().warn(
1055
+ `[Anthropic] content_block_stop missing for tool '${currentToolCall.name}' \u2014 finalizing early to prevent data bleed.`
1056
+ );
1057
+ try {
1058
+ currentToolCall.input = currentToolInputJson ? JSON.parse(currentToolInputJson) : {};
1059
+ } catch {
1060
+ currentToolCall.input = {};
1061
+ }
1062
+ yield {
1063
+ type: "tool_use_end",
1064
+ toolCall: { ...currentToolCall }
1065
+ };
1066
+ }
1053
1067
  currentToolCall = {
1054
1068
  id: contentBlock.id,
1055
1069
  name: contentBlock.name
@@ -1079,10 +1093,21 @@ var init_anthropic = __esm({
1079
1093
  try {
1080
1094
  currentToolCall.input = currentToolInputJson ? JSON.parse(currentToolInputJson) : {};
1081
1095
  } catch {
1082
- getLogger().warn(
1083
- `Failed to parse tool call arguments: ${currentToolInputJson?.slice(0, 100)}`
1084
- );
1085
- currentToolCall.input = {};
1096
+ let repaired = false;
1097
+ if (currentToolInputJson) {
1098
+ try {
1099
+ currentToolCall.input = JSON.parse(jsonrepair(currentToolInputJson));
1100
+ repaired = true;
1101
+ getLogger().debug(`Repaired JSON for tool ${currentToolCall.name}`);
1102
+ } catch {
1103
+ }
1104
+ }
1105
+ if (!repaired) {
1106
+ getLogger().warn(
1107
+ `Failed to parse tool call arguments for ${currentToolCall.name}: ${currentToolInputJson?.slice(0, 300)}`
1108
+ );
1109
+ currentToolCall.input = {};
1110
+ }
1086
1111
  }
1087
1112
  yield {
1088
1113
  type: "tool_use_end",
@@ -1639,6 +1664,30 @@ var init_openai = __esm({
1639
1664
  }
1640
1665
  };
1641
1666
  const timeoutInterval = setInterval(checkTimeout, 5e3);
1667
+ const providerName = this.name;
1668
+ const parseArguments = (builder) => {
1669
+ let input = {};
1670
+ try {
1671
+ input = builder.arguments ? JSON.parse(builder.arguments) : {};
1672
+ } catch (error) {
1673
+ console.warn(
1674
+ `[${providerName}] Failed to parse tool call arguments for ${builder.name}: ${builder.arguments?.slice(0, 300)}`
1675
+ );
1676
+ try {
1677
+ if (builder.arguments) {
1678
+ const repaired = jsonrepair(builder.arguments);
1679
+ input = JSON.parse(repaired);
1680
+ console.log(`[${providerName}] \u2713 Successfully repaired JSON for ${builder.name}`);
1681
+ }
1682
+ } catch {
1683
+ console.error(
1684
+ `[${providerName}] Cannot repair JSON for ${builder.name}, using empty object`
1685
+ );
1686
+ console.error(`[${providerName}] Original error:`, error);
1687
+ }
1688
+ }
1689
+ return input;
1690
+ };
1642
1691
  try {
1643
1692
  for await (const chunk of stream) {
1644
1693
  const delta = chunk.choices[0]?.delta;
@@ -1650,7 +1699,7 @@ var init_openai = __esm({
1650
1699
  }
1651
1700
  if (delta?.tool_calls) {
1652
1701
  for (const toolCallDelta of delta.tool_calls) {
1653
- const index = toolCallDelta.index;
1702
+ const index = toolCallDelta.index ?? toolCallBuilders.size;
1654
1703
  if (!toolCallBuilders.has(index)) {
1655
1704
  toolCallBuilders.set(index, {
1656
1705
  id: toolCallDelta.id ?? "",
@@ -1685,34 +1734,28 @@ var init_openai = __esm({
1685
1734
  }
1686
1735
  }
1687
1736
  }
1688
- }
1689
- for (const [, builder] of toolCallBuilders) {
1690
- let input = {};
1691
- try {
1692
- input = builder.arguments ? JSON.parse(builder.arguments) : {};
1693
- } catch (error) {
1694
- console.warn(
1695
- `[${this.name}] Failed to parse tool call arguments for ${builder.name}: ${builder.arguments?.slice(0, 100)}`
1696
- );
1697
- try {
1698
- if (builder.arguments) {
1699
- const repaired = jsonrepair(builder.arguments);
1700
- input = JSON.parse(repaired);
1701
- console.log(`[${this.name}] \u2713 Successfully repaired JSON for ${builder.name}`);
1702
- }
1703
- } catch {
1704
- console.error(
1705
- `[${this.name}] Cannot repair JSON for ${builder.name}, using empty object`
1706
- );
1707
- console.error(`[${this.name}] Original error:`, error);
1737
+ const finishReason = chunk.choices[0]?.finish_reason;
1738
+ if (finishReason && toolCallBuilders.size > 0) {
1739
+ for (const [, builder] of toolCallBuilders) {
1740
+ yield {
1741
+ type: "tool_use_end",
1742
+ toolCall: {
1743
+ id: builder.id,
1744
+ name: builder.name,
1745
+ input: parseArguments(builder)
1746
+ }
1747
+ };
1708
1748
  }
1749
+ toolCallBuilders.clear();
1709
1750
  }
1751
+ }
1752
+ for (const [, builder] of toolCallBuilders) {
1710
1753
  yield {
1711
1754
  type: "tool_use_end",
1712
1755
  toolCall: {
1713
1756
  id: builder.id,
1714
1757
  name: builder.name,
1715
- input
1758
+ input: parseArguments(builder)
1716
1759
  }
1717
1760
  };
1718
1761
  }
@@ -1881,7 +1924,8 @@ var init_openai = __esm({
1881
1924
  if (msg.role === "system") {
1882
1925
  result.push({ role: "system", content: this.contentToString(msg.content) });
1883
1926
  } else if (msg.role === "user") {
1884
- if (Array.isArray(msg.content) && msg.content[0]?.type === "tool_result") {
1927
+ if (Array.isArray(msg.content) && msg.content.some((b) => b.type === "tool_result")) {
1928
+ const textParts = [];
1885
1929
  for (const block of msg.content) {
1886
1930
  if (block.type === "tool_result") {
1887
1931
  const toolResult = block;
@@ -1890,8 +1934,16 @@ var init_openai = __esm({
1890
1934
  tool_call_id: toolResult.tool_use_id,
1891
1935
  content: toolResult.content
1892
1936
  });
1937
+ } else if (block.type === "text") {
1938
+ textParts.push(block.text);
1893
1939
  }
1894
1940
  }
1941
+ if (textParts.length > 0) {
1942
+ console.warn(
1943
+ `[${this.name}] User message has mixed tool_result and text blocks \u2014 text emitted as a separate user message.`
1944
+ );
1945
+ result.push({ role: "user", content: textParts.join("") });
1946
+ }
1895
1947
  } else if (Array.isArray(msg.content) && msg.content.some((b) => b.type === "image")) {
1896
1948
  const parts = [];
1897
1949
  for (const block of msg.content) {
@@ -1936,6 +1988,10 @@ var init_openai = __esm({
1936
1988
  arguments: JSON.stringify(block.input)
1937
1989
  }
1938
1990
  });
1991
+ } else {
1992
+ console.warn(
1993
+ `[${this.name}] Unexpected block type '${block.type}' in assistant message \u2014 dropping. This may indicate a message history corruption.`
1994
+ );
1939
1995
  }
1940
1996
  }
1941
1997
  if (textParts.length > 0) {
@@ -6515,8 +6571,18 @@ CONVERSATION:
6515
6571
  wasCompacted: false
6516
6572
  };
6517
6573
  }
6518
- const messagesToSummarize = conversationMessages.slice(0, -this.config.preserveLastN);
6519
- const messagesToPreserve = conversationMessages.slice(-this.config.preserveLastN);
6574
+ let preserveStart = conversationMessages.length - this.config.preserveLastN;
6575
+ if (preserveStart > 0) {
6576
+ while (preserveStart > 0) {
6577
+ const first = conversationMessages[preserveStart];
6578
+ if (!first) break;
6579
+ const isToolResult = Array.isArray(first.content) && first.content.length > 0 && first.content[0]?.type === "tool_result";
6580
+ if (!isToolResult) break;
6581
+ preserveStart--;
6582
+ }
6583
+ }
6584
+ const messagesToSummarize = conversationMessages.slice(0, preserveStart);
6585
+ const messagesToPreserve = conversationMessages.slice(preserveStart);
6520
6586
  if (messagesToSummarize.length === 0) {
6521
6587
  return {
6522
6588
  messages,
@@ -8165,6 +8231,12 @@ var init_registry4 = __esm({
8165
8231
  return `${field} (${issue.message.toLowerCase()})`;
8166
8232
  });
8167
8233
  errorMessage = `Invalid tool input \u2014 ${fields.join(", ")}`;
8234
+ const allUndefined = error.issues.every(
8235
+ (i) => i.message.toLowerCase().includes("received undefined")
8236
+ );
8237
+ if (allUndefined && error.issues.length > 1) {
8238
+ errorMessage += ". All parameters are missing \u2014 this is likely a JSON serialization error on our side. Please retry with the same arguments.";
8239
+ }
8168
8240
  } else {
8169
8241
  const rawMessage = error instanceof Error ? error.message : String(error);
8170
8242
  errorMessage = humanizeError(rawMessage, name);
@@ -8751,8 +8823,8 @@ async function killOrphanedTestProcesses() {
8751
8823
  }
8752
8824
  let killed = 0;
8753
8825
  try {
8754
- const { execa: execa13 } = await import('execa');
8755
- const result = await execa13("pgrep", ["-f", "vitest|jest.*--worker"], {
8826
+ const { execa: execa14 } = await import('execa');
8827
+ const result = await execa14("pgrep", ["-f", "vitest|jest.*--worker"], {
8756
8828
  reject: false
8757
8829
  });
8758
8830
  const pids = result.stdout.split("\n").map((s) => parseInt(s.trim(), 10)).filter((pid) => !isNaN(pid) && pid !== process.pid && pid !== process.ppid);
@@ -14313,6 +14385,15 @@ __export(bash_exports, {
14313
14385
  commandExistsTool: () => commandExistsTool,
14314
14386
  getEnvTool: () => getEnvTool
14315
14387
  });
14388
+ function getShellCommandPart(command) {
14389
+ const firstNewline = command.indexOf("\n");
14390
+ if (firstNewline === -1) return command;
14391
+ const firstLine = command.slice(0, firstNewline);
14392
+ if (/<<-?\s*['"]?\w/.test(firstLine)) {
14393
+ return firstLine;
14394
+ }
14395
+ return command;
14396
+ }
14316
14397
  function isEnvVarSafe(name) {
14317
14398
  if (SAFE_ENV_VARS.has(name)) {
14318
14399
  return true;
@@ -14333,14 +14414,14 @@ function truncateOutput(output, maxLength = 5e4) {
14333
14414
 
14334
14415
  [Output truncated - ${output.length - maxLength} more characters]`;
14335
14416
  }
14336
- var DEFAULT_TIMEOUT_MS, MAX_OUTPUT_SIZE, DANGEROUS_PATTERNS, SAFE_ENV_VARS, SENSITIVE_ENV_PATTERNS, bashExecTool, bashBackgroundTool, commandExistsTool, getEnvTool, bashTools;
14417
+ var DEFAULT_TIMEOUT_MS, MAX_OUTPUT_SIZE, DANGEROUS_PATTERNS_FULL, DANGEROUS_PATTERNS_SHELL_ONLY, SAFE_ENV_VARS, SENSITIVE_ENV_PATTERNS, bashExecTool, bashBackgroundTool, commandExistsTool, getEnvTool, bashTools;
14337
14418
  var init_bash = __esm({
14338
14419
  "src/tools/bash.ts"() {
14339
14420
  init_registry4();
14340
14421
  init_errors();
14341
14422
  DEFAULT_TIMEOUT_MS = 12e4;
14342
14423
  MAX_OUTPUT_SIZE = 1024 * 1024;
14343
- DANGEROUS_PATTERNS = [
14424
+ DANGEROUS_PATTERNS_FULL = [
14344
14425
  /\brm\s+-rf\s+\/(?!\w)/,
14345
14426
  // rm -rf / (root)
14346
14427
  /\bsudo\s+rm\s+-rf/,
@@ -14353,14 +14434,6 @@ var init_bash = __esm({
14353
14434
  // Format filesystem
14354
14435
  /\bformat\s+/,
14355
14436
  // Windows format
14356
- /`[^`]+`/,
14357
- // Backtick command substitution
14358
- /\$\([^)]+\)/,
14359
- // $() command substitution
14360
- /\beval\s+/,
14361
- // eval command
14362
- /\bsource\s+/,
14363
- // source command (can execute arbitrary scripts)
14364
14437
  />\s*\/etc\//,
14365
14438
  // Write to /etc
14366
14439
  />\s*\/root\//,
@@ -14374,6 +14447,16 @@ var init_bash = __esm({
14374
14447
  /\bwget\s+.*\|\s*(ba)?sh/
14375
14448
  // wget | sh pattern
14376
14449
  ];
14450
+ DANGEROUS_PATTERNS_SHELL_ONLY = [
14451
+ /`[^`]+`/,
14452
+ // Backtick command substitution
14453
+ /\$\([^)]+\)/,
14454
+ // $() command substitution
14455
+ /\beval\s+/,
14456
+ // eval command (shell eval, not JS eval())
14457
+ /\bsource\s+/
14458
+ // source command (can execute arbitrary scripts)
14459
+ ];
14377
14460
  SAFE_ENV_VARS = /* @__PURE__ */ new Set([
14378
14461
  // System info (non-sensitive)
14379
14462
  "PATH",
@@ -14440,13 +14523,21 @@ Examples:
14440
14523
  env: z.record(z.string(), z.string()).optional().describe("Environment variables")
14441
14524
  }),
14442
14525
  async execute({ command, cwd, timeout, env: env2 }) {
14443
- for (const pattern of DANGEROUS_PATTERNS) {
14526
+ const shellPart = getShellCommandPart(command);
14527
+ for (const pattern of DANGEROUS_PATTERNS_FULL) {
14444
14528
  if (pattern.test(command)) {
14445
14529
  throw new ToolError(`Potentially dangerous command blocked: ${command.slice(0, 100)}`, {
14446
14530
  tool: "bash_exec"
14447
14531
  });
14448
14532
  }
14449
14533
  }
14534
+ for (const pattern of DANGEROUS_PATTERNS_SHELL_ONLY) {
14535
+ if (pattern.test(shellPart)) {
14536
+ throw new ToolError(`Potentially dangerous command blocked: ${command.slice(0, 100)}`, {
14537
+ tool: "bash_exec"
14538
+ });
14539
+ }
14540
+ }
14450
14541
  const startTime = performance.now();
14451
14542
  const timeoutMs = timeout ?? DEFAULT_TIMEOUT_MS;
14452
14543
  const { CommandHeartbeat: CommandHeartbeat2 } = await Promise.resolve().then(() => (init_heartbeat(), heartbeat_exports));
@@ -14528,13 +14619,21 @@ Examples:
14528
14619
  env: z.record(z.string(), z.string()).optional().describe("Environment variables")
14529
14620
  }),
14530
14621
  async execute({ command, cwd, env: env2 }) {
14531
- for (const pattern of DANGEROUS_PATTERNS) {
14622
+ const shellPart = getShellCommandPart(command);
14623
+ for (const pattern of DANGEROUS_PATTERNS_FULL) {
14532
14624
  if (pattern.test(command)) {
14533
14625
  throw new ToolError(`Potentially dangerous command blocked: ${command.slice(0, 100)}`, {
14534
14626
  tool: "bash_background"
14535
14627
  });
14536
14628
  }
14537
14629
  }
14630
+ for (const pattern of DANGEROUS_PATTERNS_SHELL_ONLY) {
14631
+ if (pattern.test(shellPart)) {
14632
+ throw new ToolError(`Potentially dangerous command blocked: ${command.slice(0, 100)}`, {
14633
+ tool: "bash_background"
14634
+ });
14635
+ }
14636
+ }
14538
14637
  try {
14539
14638
  const filteredEnv = {};
14540
14639
  for (const [key, value] of Object.entries(process.env)) {
@@ -18770,6 +18869,680 @@ var init_tools = __esm({
18770
18869
  }
18771
18870
  });
18772
18871
 
18872
+ // src/cli/repl/hooks/types.ts
18873
+ function isHookEvent(value) {
18874
+ return typeof value === "string" && HOOK_EVENTS.includes(value);
18875
+ }
18876
+ function isHookType(value) {
18877
+ return value === "command" || value === "prompt";
18878
+ }
18879
+ function isHookAction(value) {
18880
+ return value === "allow" || value === "deny" || value === "modify";
18881
+ }
18882
+ var HOOK_EVENTS;
18883
+ var init_types8 = __esm({
18884
+ "src/cli/repl/hooks/types.ts"() {
18885
+ HOOK_EVENTS = [
18886
+ "PreToolUse",
18887
+ "PostToolUse",
18888
+ "Stop",
18889
+ "SubagentStop",
18890
+ "PreCompact",
18891
+ "SessionStart",
18892
+ "SessionEnd"
18893
+ ];
18894
+ }
18895
+ });
18896
+ function createHookRegistry() {
18897
+ return new HookRegistry();
18898
+ }
18899
+ var HookRegistry;
18900
+ var init_registry5 = __esm({
18901
+ "src/cli/repl/hooks/registry.ts"() {
18902
+ init_types8();
18903
+ HookRegistry = class {
18904
+ /** Hooks indexed by event type for O(1) lookup */
18905
+ hooksByEvent;
18906
+ /** Hooks indexed by ID for quick access */
18907
+ hooksById;
18908
+ constructor() {
18909
+ this.hooksByEvent = /* @__PURE__ */ new Map();
18910
+ this.hooksById = /* @__PURE__ */ new Map();
18911
+ }
18912
+ /**
18913
+ * Register a hook
18914
+ *
18915
+ * @param hook - Hook definition to register
18916
+ * @throws Error if hook with same ID already exists
18917
+ */
18918
+ register(hook) {
18919
+ if (this.hooksById.has(hook.id)) {
18920
+ throw new Error(`Hook with ID '${hook.id}' already exists`);
18921
+ }
18922
+ this.hooksById.set(hook.id, hook);
18923
+ const eventHooks = this.hooksByEvent.get(hook.event) ?? [];
18924
+ eventHooks.push(hook);
18925
+ this.hooksByEvent.set(hook.event, eventHooks);
18926
+ }
18927
+ /**
18928
+ * Unregister a hook by ID
18929
+ *
18930
+ * @param hookId - ID of hook to remove
18931
+ * @returns true if hook was removed, false if not found
18932
+ */
18933
+ unregister(hookId) {
18934
+ const hook = this.hooksById.get(hookId);
18935
+ if (!hook) {
18936
+ return false;
18937
+ }
18938
+ this.hooksById.delete(hookId);
18939
+ const eventHooks = this.hooksByEvent.get(hook.event);
18940
+ if (eventHooks) {
18941
+ const index = eventHooks.findIndex((h) => h.id === hookId);
18942
+ if (index !== -1) {
18943
+ eventHooks.splice(index, 1);
18944
+ }
18945
+ if (eventHooks.length === 0) {
18946
+ this.hooksByEvent.delete(hook.event);
18947
+ }
18948
+ }
18949
+ return true;
18950
+ }
18951
+ /**
18952
+ * Get all hooks for an event type
18953
+ *
18954
+ * @param event - Event type to query
18955
+ * @returns Array of hooks for the event (empty if none)
18956
+ */
18957
+ getHooksForEvent(event) {
18958
+ return this.hooksByEvent.get(event) ?? [];
18959
+ }
18960
+ /**
18961
+ * Get hooks that match a specific event and optionally a tool
18962
+ *
18963
+ * Filters hooks by:
18964
+ * 1. Event type match
18965
+ * 2. Hook enabled status
18966
+ * 3. Tool name pattern match (if toolName provided)
18967
+ *
18968
+ * @param event - Event type to match
18969
+ * @param toolName - Optional tool name to match against matcher patterns
18970
+ * @returns Array of matching enabled hooks
18971
+ */
18972
+ getMatchingHooks(event, toolName) {
18973
+ const eventHooks = this.getHooksForEvent(event);
18974
+ return eventHooks.filter((hook) => {
18975
+ if (hook.enabled === false) {
18976
+ return false;
18977
+ }
18978
+ if (!hook.matcher) {
18979
+ return true;
18980
+ }
18981
+ if (!toolName) {
18982
+ return !hook.matcher;
18983
+ }
18984
+ return this.matchesPattern(toolName, hook.matcher);
18985
+ });
18986
+ }
18987
+ /**
18988
+ * Load hooks from a config file
18989
+ *
18990
+ * Expects a JSON file with structure:
18991
+ * ```json
18992
+ * {
18993
+ * "version": 1,
18994
+ * "hooks": [...]
18995
+ * }
18996
+ * ```
18997
+ *
18998
+ * @param filePath - Path to hooks config file (JSON)
18999
+ * @throws Error if file cannot be read or parsed
19000
+ */
19001
+ async loadFromFile(filePath) {
19002
+ try {
19003
+ await access(filePath);
19004
+ const content = await readFile(filePath, "utf-8");
19005
+ const config = JSON.parse(content);
19006
+ if (typeof config.version !== "number") {
19007
+ throw new Error("Invalid hooks config: missing version");
19008
+ }
19009
+ if (!Array.isArray(config.hooks)) {
19010
+ throw new Error("Invalid hooks config: hooks must be an array");
19011
+ }
19012
+ this.clear();
19013
+ for (const hook of config.hooks) {
19014
+ this.validateHookDefinition(hook);
19015
+ this.register(hook);
19016
+ }
19017
+ } catch (error) {
19018
+ if (error.code === "ENOENT") {
19019
+ return;
19020
+ }
19021
+ throw error;
19022
+ }
19023
+ }
19024
+ /**
19025
+ * Save hooks to a config file
19026
+ *
19027
+ * Creates the directory if it doesn't exist.
19028
+ *
19029
+ * @param filePath - Path to save hooks config
19030
+ */
19031
+ async saveToFile(filePath) {
19032
+ await mkdir(dirname(filePath), { recursive: true });
19033
+ const config = {
19034
+ version: 1,
19035
+ hooks: this.getAllHooks()
19036
+ };
19037
+ await writeFile(filePath, JSON.stringify(config, null, 2), "utf-8");
19038
+ }
19039
+ /**
19040
+ * Clear all registered hooks
19041
+ */
19042
+ clear() {
19043
+ this.hooksByEvent.clear();
19044
+ this.hooksById.clear();
19045
+ }
19046
+ /**
19047
+ * Get all registered hooks
19048
+ *
19049
+ * @returns Array of all hook definitions
19050
+ */
19051
+ getAllHooks() {
19052
+ return Array.from(this.hooksById.values());
19053
+ }
19054
+ /**
19055
+ * Get a hook by ID
19056
+ *
19057
+ * @param hookId - Hook ID to find
19058
+ * @returns Hook definition or undefined if not found
19059
+ */
19060
+ getHookById(hookId) {
19061
+ return this.hooksById.get(hookId);
19062
+ }
19063
+ /**
19064
+ * Check if registry has any hooks for an event
19065
+ *
19066
+ * @param event - Event to check
19067
+ * @returns true if any hooks are registered for the event
19068
+ */
19069
+ hasHooksForEvent(event) {
19070
+ const hooks = this.hooksByEvent.get(event);
19071
+ return hooks !== void 0 && hooks.length > 0;
19072
+ }
19073
+ /**
19074
+ * Update an existing hook
19075
+ *
19076
+ * @param hookId - ID of hook to update
19077
+ * @param updates - Partial hook definition with updates
19078
+ * @returns true if hook was updated, false if not found
19079
+ */
19080
+ updateHook(hookId, updates) {
19081
+ const existing = this.hooksById.get(hookId);
19082
+ if (!existing) {
19083
+ return false;
19084
+ }
19085
+ const eventChanging = updates.event && updates.event !== existing.event;
19086
+ const oldEvent = existing.event;
19087
+ Object.assign(existing, updates);
19088
+ if (eventChanging && updates.event) {
19089
+ const oldEventHooks = this.hooksByEvent.get(oldEvent);
19090
+ if (oldEventHooks) {
19091
+ const index = oldEventHooks.findIndex((h) => h.id === hookId);
19092
+ if (index !== -1) {
19093
+ oldEventHooks.splice(index, 1);
19094
+ }
19095
+ if (oldEventHooks.length === 0) {
19096
+ this.hooksByEvent.delete(oldEvent);
19097
+ }
19098
+ }
19099
+ const newEventHooks = this.hooksByEvent.get(updates.event) ?? [];
19100
+ newEventHooks.push(existing);
19101
+ this.hooksByEvent.set(updates.event, newEventHooks);
19102
+ }
19103
+ return true;
19104
+ }
19105
+ /**
19106
+ * Enable or disable a hook
19107
+ *
19108
+ * @param hookId - ID of hook to toggle
19109
+ * @param enabled - New enabled state
19110
+ * @returns true if hook was updated, false if not found
19111
+ */
19112
+ setEnabled(hookId, enabled) {
19113
+ return this.updateHook(hookId, { enabled });
19114
+ }
19115
+ /**
19116
+ * Get count of registered hooks
19117
+ */
19118
+ get size() {
19119
+ return this.hooksById.size;
19120
+ }
19121
+ /**
19122
+ * Check if a tool name matches a glob-like pattern
19123
+ *
19124
+ * Supported patterns:
19125
+ * - `*` matches any tool
19126
+ * - `Edit*` matches Edit, EditFile, etc. (prefix match)
19127
+ * - `*File` matches ReadFile, WriteFile, etc. (suffix match)
19128
+ * - `*Code*` matches anything containing "Code"
19129
+ * - `Bash` exact match
19130
+ *
19131
+ * @param toolName - Tool name to check
19132
+ * @param pattern - Glob-like pattern
19133
+ * @returns true if tool name matches pattern
19134
+ */
19135
+ matchesPattern(toolName, pattern) {
19136
+ if (pattern === "*") {
19137
+ return true;
19138
+ }
19139
+ if (!pattern.includes("*")) {
19140
+ return toolName === pattern;
19141
+ }
19142
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
19143
+ const regexPattern = escaped.replace(/\*/g, ".*");
19144
+ const regex = new RegExp(`^${regexPattern}$`);
19145
+ return regex.test(toolName);
19146
+ }
19147
+ /**
19148
+ * Validate a hook definition has required fields
19149
+ *
19150
+ * @param hook - Hook to validate
19151
+ * @throws Error if hook is invalid
19152
+ */
19153
+ validateHookDefinition(hook) {
19154
+ if (!hook || typeof hook !== "object") {
19155
+ throw new Error("Hook definition must be an object");
19156
+ }
19157
+ const h = hook;
19158
+ if (typeof h.id !== "string" || h.id.length === 0) {
19159
+ throw new Error("Hook definition must have a non-empty id");
19160
+ }
19161
+ if (typeof h.event !== "string") {
19162
+ throw new Error("Hook definition must have an event type");
19163
+ }
19164
+ if (!isHookEvent(h.event)) {
19165
+ throw new Error(
19166
+ `Invalid hook event type: ${h.event}. Valid events: ${HOOK_EVENTS.join(", ")}`
19167
+ );
19168
+ }
19169
+ if (typeof h.type !== "string") {
19170
+ throw new Error("Hook definition must have a type (command or prompt)");
19171
+ }
19172
+ if (!isHookType(h.type)) {
19173
+ throw new Error(`Invalid hook type: ${h.type}. Valid types: command, prompt`);
19174
+ }
19175
+ if (h.type === "command" && typeof h.command !== "string") {
19176
+ throw new Error("Command hooks must have a command string");
19177
+ }
19178
+ if (h.type === "prompt" && typeof h.prompt !== "string") {
19179
+ throw new Error("Prompt hooks must have a prompt string");
19180
+ }
19181
+ }
19182
+ };
19183
+ }
19184
+ });
19185
+ function createHookExecutor(options) {
19186
+ return new HookExecutor(options);
19187
+ }
19188
+ var DEFAULT_TIMEOUT_MS5, MAX_OUTPUT_SIZE3, HookExecutor;
19189
+ var init_executor = __esm({
19190
+ "src/cli/repl/hooks/executor.ts"() {
19191
+ DEFAULT_TIMEOUT_MS5 = 3e4;
19192
+ MAX_OUTPUT_SIZE3 = 64 * 1024;
19193
+ HookExecutor = class {
19194
+ defaultTimeout;
19195
+ shell;
19196
+ cwd;
19197
+ /**
19198
+ * Create a new HookExecutor
19199
+ *
19200
+ * @param options - Configuration options for the executor
19201
+ */
19202
+ constructor(options) {
19203
+ this.defaultTimeout = options?.defaultTimeout ?? DEFAULT_TIMEOUT_MS5;
19204
+ this.shell = options?.shell ?? (process.platform === "win32" ? "cmd.exe" : "/bin/bash");
19205
+ this.cwd = options?.cwd ?? process.cwd();
19206
+ }
19207
+ /**
19208
+ * Execute all hooks for an event from the registry
19209
+ *
19210
+ * @description Retrieves hooks from the registry that match the event and context,
19211
+ * executes them in order, and aggregates the results. Execution stops early if
19212
+ * a hook denies the operation (for PreToolUse events).
19213
+ *
19214
+ * @param registry - The hook registry to query for hooks
19215
+ * @param context - The execution context with event details
19216
+ * @returns Aggregated results from all hook executions
19217
+ *
19218
+ * @example
19219
+ * ```typescript
19220
+ * const result = await executor.executeHooks(registry, context);
19221
+ * console.log(`Executed ${result.results.length} hooks in ${result.duration}ms`);
19222
+ * ```
19223
+ */
19224
+ async executeHooks(registry, context) {
19225
+ const startTime = performance.now();
19226
+ const hooks = registry.getHooksForEvent(context.event);
19227
+ const matchingHooks = hooks.filter((hook) => this.matchesContext(hook, context));
19228
+ const enabledHooks = matchingHooks.filter((hook) => hook.enabled !== false);
19229
+ if (enabledHooks.length === 0) {
19230
+ return {
19231
+ event: context.event,
19232
+ results: [],
19233
+ allSucceeded: true,
19234
+ shouldContinue: true,
19235
+ duration: performance.now() - startTime
19236
+ };
19237
+ }
19238
+ const results = [];
19239
+ let shouldContinue = true;
19240
+ let modifiedInput;
19241
+ let allSucceeded = true;
19242
+ for (const hook of enabledHooks) {
19243
+ if (!shouldContinue) {
19244
+ break;
19245
+ }
19246
+ let result;
19247
+ try {
19248
+ if (hook.type === "command") {
19249
+ result = await this.executeCommandHook(hook, context);
19250
+ } else {
19251
+ result = await this.executePromptHook(hook, context);
19252
+ }
19253
+ } catch (error) {
19254
+ result = {
19255
+ hookId: hook.id,
19256
+ success: false,
19257
+ error: error instanceof Error ? error.message : String(error),
19258
+ duration: 0
19259
+ };
19260
+ }
19261
+ results.push(result);
19262
+ if (!result.success) {
19263
+ allSucceeded = false;
19264
+ if (!hook.continueOnError) {
19265
+ shouldContinue = false;
19266
+ }
19267
+ }
19268
+ if (result.action === "deny") {
19269
+ shouldContinue = false;
19270
+ } else if (result.action === "modify" && result.modifiedInput) {
19271
+ modifiedInput = result.modifiedInput;
19272
+ }
19273
+ if (hook.type === "command" && result.exitCode === 1 && context.event === "PreToolUse") {
19274
+ shouldContinue = false;
19275
+ }
19276
+ }
19277
+ return {
19278
+ event: context.event,
19279
+ results,
19280
+ allSucceeded,
19281
+ shouldContinue,
19282
+ modifiedInput,
19283
+ duration: performance.now() - startTime
19284
+ };
19285
+ }
19286
+ /**
19287
+ * Execute a single command hook via shell
19288
+ *
19289
+ * @description Runs the hook's command in a shell subprocess with environment
19290
+ * variables set based on the hook context. Handles timeouts and captures
19291
+ * stdout/stderr.
19292
+ *
19293
+ * @param hook - The hook definition containing the command to execute
19294
+ * @param context - The execution context
19295
+ * @returns Result of the command execution
19296
+ */
19297
+ async executeCommandHook(hook, context) {
19298
+ const startTime = performance.now();
19299
+ if (!hook.command) {
19300
+ return {
19301
+ hookId: hook.id,
19302
+ success: false,
19303
+ error: "Command hook has no command defined",
19304
+ duration: performance.now() - startTime
19305
+ };
19306
+ }
19307
+ const timeoutMs = hook.timeout ?? this.defaultTimeout;
19308
+ const env2 = this.buildEnvironment(context);
19309
+ try {
19310
+ const options = {
19311
+ cwd: this.cwd,
19312
+ timeout: timeoutMs,
19313
+ env: { ...process.env, ...env2 },
19314
+ shell: this.shell,
19315
+ reject: false,
19316
+ maxBuffer: MAX_OUTPUT_SIZE3
19317
+ };
19318
+ const result = await execa(hook.command, options);
19319
+ const stdout = typeof result.stdout === "string" ? result.stdout : String(result.stdout ?? "");
19320
+ const stderr = typeof result.stderr === "string" ? result.stderr : String(result.stderr ?? "");
19321
+ const exitCode = result.exitCode ?? 0;
19322
+ const success = exitCode === 0;
19323
+ const output = [stdout, stderr].filter(Boolean).join("\n").trim();
19324
+ return {
19325
+ hookId: hook.id,
19326
+ success,
19327
+ output: output || void 0,
19328
+ error: !success && stderr ? stderr : void 0,
19329
+ duration: performance.now() - startTime,
19330
+ exitCode
19331
+ };
19332
+ } catch (error) {
19333
+ if (error.timedOut) {
19334
+ return {
19335
+ hookId: hook.id,
19336
+ success: false,
19337
+ error: `Hook timed out after ${timeoutMs}ms`,
19338
+ duration: performance.now() - startTime
19339
+ };
19340
+ }
19341
+ return {
19342
+ hookId: hook.id,
19343
+ success: false,
19344
+ error: error instanceof Error ? error.message : String(error),
19345
+ duration: performance.now() - startTime
19346
+ };
19347
+ }
19348
+ }
19349
+ /**
19350
+ * Execute a single prompt hook via LLM
19351
+ *
19352
+ * @description Evaluates the hook's prompt using an LLM to determine the action
19353
+ * to take. Currently simplified - returns "allow" by default. Full implementation
19354
+ * would require LLM provider integration.
19355
+ *
19356
+ * @param hook - The hook definition containing the prompt to evaluate
19357
+ * @param context - The execution context
19358
+ * @returns Result of the prompt evaluation with action
19359
+ *
19360
+ * @remarks
19361
+ * This is a simplified implementation. A full implementation would:
19362
+ * 1. Format the prompt with context variables
19363
+ * 2. Send the prompt to an LLM provider
19364
+ * 3. Parse the response for action (allow/deny/modify)
19365
+ * 4. Extract any modified input if action is "modify"
19366
+ */
19367
+ async executePromptHook(hook, context) {
19368
+ const startTime = performance.now();
19369
+ if (!hook.prompt) {
19370
+ return {
19371
+ hookId: hook.id,
19372
+ success: false,
19373
+ error: "Prompt hook has no prompt defined",
19374
+ duration: performance.now() - startTime
19375
+ };
19376
+ }
19377
+ const formattedPrompt = this.formatPrompt(hook.prompt, context);
19378
+ const action = "allow";
19379
+ return {
19380
+ hookId: hook.id,
19381
+ success: true,
19382
+ output: `Prompt evaluated: ${formattedPrompt.slice(0, 100)}...`,
19383
+ duration: performance.now() - startTime,
19384
+ action
19385
+ };
19386
+ }
19387
+ /**
19388
+ * Build environment variables for hook execution
19389
+ *
19390
+ * @description Creates a set of environment variables that provide context
19391
+ * to command hooks. Variables include event type, tool information, session ID,
19392
+ * and project path.
19393
+ *
19394
+ * @param context - The hook execution context
19395
+ * @returns Record of environment variable names to values
19396
+ */
19397
+ buildEnvironment(context) {
19398
+ const env2 = {
19399
+ COCO_HOOK_EVENT: context.event,
19400
+ COCO_SESSION_ID: context.sessionId,
19401
+ COCO_PROJECT_PATH: context.projectPath,
19402
+ COCO_HOOK_TIMESTAMP: context.timestamp.toISOString()
19403
+ };
19404
+ if (context.toolName) {
19405
+ env2.COCO_TOOL_NAME = context.toolName;
19406
+ }
19407
+ if (context.toolInput) {
19408
+ try {
19409
+ env2.COCO_TOOL_INPUT = JSON.stringify(context.toolInput);
19410
+ } catch {
19411
+ env2.COCO_TOOL_INPUT = String(context.toolInput);
19412
+ }
19413
+ }
19414
+ if (context.toolResult) {
19415
+ try {
19416
+ env2.COCO_TOOL_RESULT = JSON.stringify(context.toolResult);
19417
+ } catch {
19418
+ env2.COCO_TOOL_RESULT = String(context.toolResult);
19419
+ }
19420
+ }
19421
+ if (context.metadata) {
19422
+ for (const [key, value] of Object.entries(context.metadata)) {
19423
+ const envKey = `COCO_META_${key.toUpperCase().replace(/[^A-Z0-9_]/g, "_")}`;
19424
+ try {
19425
+ env2[envKey] = typeof value === "string" ? value : JSON.stringify(value);
19426
+ } catch {
19427
+ env2[envKey] = String(value);
19428
+ }
19429
+ }
19430
+ }
19431
+ return env2;
19432
+ }
19433
+ /**
19434
+ * Check if a hook matches the given context
19435
+ *
19436
+ * @description Evaluates whether a hook should be executed based on its
19437
+ * matcher pattern and the context's tool name. Supports glob-like patterns.
19438
+ *
19439
+ * @param hook - The hook definition to check
19440
+ * @param context - The execution context
19441
+ * @returns true if the hook should be executed
19442
+ */
19443
+ matchesContext(hook, context) {
19444
+ if (!hook.matcher) {
19445
+ return true;
19446
+ }
19447
+ if (!context.toolName) {
19448
+ return true;
19449
+ }
19450
+ return this.matchPattern(hook.matcher, context.toolName);
19451
+ }
19452
+ /**
19453
+ * Match a glob-like pattern against a string
19454
+ *
19455
+ * @description Supports simple glob patterns:
19456
+ * - "*" matches any sequence of characters
19457
+ * - "?" matches any single character
19458
+ * - Exact match otherwise
19459
+ *
19460
+ * @param pattern - The pattern to match
19461
+ * @param value - The value to match against
19462
+ * @returns true if the value matches the pattern
19463
+ */
19464
+ matchPattern(pattern, value) {
19465
+ if (!pattern.includes("*") && !pattern.includes("?")) {
19466
+ return pattern === value;
19467
+ }
19468
+ const regexPattern = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
19469
+ const regex = new RegExp(`^${regexPattern}$`);
19470
+ return regex.test(value);
19471
+ }
19472
+ /**
19473
+ * Format a prompt template with context values
19474
+ *
19475
+ * @description Replaces template variables in the prompt with values from
19476
+ * the context. Variables are in the format {{variableName}}.
19477
+ *
19478
+ * Supported variables:
19479
+ * - {{event}} - The hook event type
19480
+ * - {{toolName}} - Name of the tool (if applicable)
19481
+ * - {{toolInput}} - JSON string of tool input
19482
+ * - {{toolResult}} - JSON string of tool result
19483
+ * - {{sessionId}} - Session ID
19484
+ * - {{projectPath}} - Project path
19485
+ *
19486
+ * @param prompt - The prompt template
19487
+ * @param context - The execution context
19488
+ * @returns Formatted prompt with variables replaced
19489
+ */
19490
+ formatPrompt(prompt, context) {
19491
+ let formatted = prompt;
19492
+ formatted = formatted.replace(/\{\{event\}\}/g, context.event);
19493
+ formatted = formatted.replace(/\{\{toolName\}\}/g, context.toolName ?? "N/A");
19494
+ formatted = formatted.replace(/\{\{sessionId\}\}/g, context.sessionId);
19495
+ formatted = formatted.replace(/\{\{projectPath\}\}/g, context.projectPath);
19496
+ if (context.toolInput) {
19497
+ try {
19498
+ formatted = formatted.replace(
19499
+ /\{\{toolInput\}\}/g,
19500
+ JSON.stringify(context.toolInput, null, 2)
19501
+ );
19502
+ } catch {
19503
+ formatted = formatted.replace(/\{\{toolInput\}\}/g, String(context.toolInput));
19504
+ }
19505
+ } else {
19506
+ formatted = formatted.replace(/\{\{toolInput\}\}/g, "N/A");
19507
+ }
19508
+ if (context.toolResult) {
19509
+ try {
19510
+ formatted = formatted.replace(
19511
+ /\{\{toolResult\}\}/g,
19512
+ JSON.stringify(context.toolResult, null, 2)
19513
+ );
19514
+ } catch {
19515
+ formatted = formatted.replace(/\{\{toolResult\}\}/g, String(context.toolResult));
19516
+ }
19517
+ } else {
19518
+ formatted = formatted.replace(/\{\{toolResult\}\}/g, "N/A");
19519
+ }
19520
+ return formatted;
19521
+ }
19522
+ };
19523
+ }
19524
+ });
19525
+
19526
+ // src/cli/repl/hooks/index.ts
19527
+ var hooks_exports = {};
19528
+ __export(hooks_exports, {
19529
+ HOOK_EVENTS: () => HOOK_EVENTS,
19530
+ HookExecutor: () => HookExecutor,
19531
+ HookRegistry: () => HookRegistry,
19532
+ createHookExecutor: () => createHookExecutor,
19533
+ createHookRegistry: () => createHookRegistry,
19534
+ isHookAction: () => isHookAction,
19535
+ isHookEvent: () => isHookEvent,
19536
+ isHookType: () => isHookType
19537
+ });
19538
+ var init_hooks = __esm({
19539
+ "src/cli/repl/hooks/index.ts"() {
19540
+ init_types8();
19541
+ init_registry5();
19542
+ init_executor();
19543
+ }
19544
+ });
19545
+
18773
19546
  // src/cli/repl/input/message-queue.ts
18774
19547
  function createMessageQueue(maxSize = 50) {
18775
19548
  let messages = [];
@@ -23083,9 +23856,9 @@ async function createCliPhaseContext(projectPath, _onUserInput) {
23083
23856
  },
23084
23857
  bash: {
23085
23858
  async exec(command, options = {}) {
23086
- const { execa: execa13 } = await import('execa');
23859
+ const { execa: execa14 } = await import('execa');
23087
23860
  try {
23088
- const result = await execa13(command, {
23861
+ const result = await execa14(command, {
23089
23862
  shell: true,
23090
23863
  cwd: options.cwd || projectPath,
23091
23864
  timeout: options.timeout,
@@ -31830,10 +32603,10 @@ var resumeCommand = {
31830
32603
 
31831
32604
  // src/cli/repl/version-check.ts
31832
32605
  init_version();
31833
- var NPM_REGISTRY_URL = "https://registry.npmjs.org/@corbat-tech/coco";
32606
+ var NPM_REGISTRY_URL = "https://registry.npmjs.org/@corbat-tech/coco/latest";
31834
32607
  var CHECK_INTERVAL_MS = 24 * 60 * 60 * 1e3;
31835
- var FETCH_TIMEOUT_MS = 2e3;
31836
- var STARTUP_TIMEOUT_MS = 2500;
32608
+ var FETCH_TIMEOUT_MS = 5e3;
32609
+ var STARTUP_TIMEOUT_MS = 5500;
31837
32610
  var CACHE_DIR = path34__default.join(os4__default.homedir(), ".coco");
31838
32611
  var CACHE_FILE = path34__default.join(CACHE_DIR, "version-check-cache.json");
31839
32612
  function compareVersions(a, b) {
@@ -31881,7 +32654,7 @@ async function fetchLatestVersion() {
31881
32654
  return null;
31882
32655
  }
31883
32656
  const data = await response.json();
31884
- return data["dist-tags"]?.latest ?? null;
32657
+ return data.version ?? null;
31885
32658
  } finally {
31886
32659
  clearTimeout(timeout);
31887
32660
  }
@@ -31953,10 +32726,14 @@ function printUpdateBanner(updateInfo) {
31953
32726
  console.log();
31954
32727
  }
31955
32728
  async function checkForUpdatesInteractive() {
32729
+ let startupTimerId;
31956
32730
  const updateInfo = await Promise.race([
31957
32731
  checkForUpdates(),
31958
- new Promise((resolve4) => setTimeout(() => resolve4(null), STARTUP_TIMEOUT_MS))
32732
+ new Promise((resolve4) => {
32733
+ startupTimerId = setTimeout(() => resolve4(null), STARTUP_TIMEOUT_MS);
32734
+ })
31959
32735
  ]);
32736
+ clearTimeout(startupTimerId);
31960
32737
  if (!updateInfo) return;
31961
32738
  const p45 = await import('@clack/prompts');
31962
32739
  printUpdateBanner(updateInfo);
@@ -31969,10 +32746,10 @@ async function checkForUpdatesInteractive() {
31969
32746
  console.log(chalk25.dim(` Running: ${updateInfo.updateCommand}`));
31970
32747
  console.log();
31971
32748
  try {
31972
- const { execa: execa13 } = await import('execa');
32749
+ const { execa: execa14 } = await import('execa');
31973
32750
  const [cmd, ...args] = updateInfo.updateCommand.split(" ");
31974
32751
  if (!cmd) return;
31975
- await execa13(cmd, args, { stdio: "inherit", timeout: 12e4 });
32752
+ await execa14(cmd, args, { stdio: "inherit", timeout: 12e4 });
31976
32753
  console.log();
31977
32754
  console.log(chalk25.green(" \u2713 Updated! Run coco again to start the new version."));
31978
32755
  console.log();
@@ -39272,7 +40049,7 @@ var imageTools = [readImageTool];
39272
40049
  init_registry4();
39273
40050
  init_errors();
39274
40051
  var path43 = await import('path');
39275
- var DANGEROUS_PATTERNS2 = [
40052
+ var DANGEROUS_PATTERNS = [
39276
40053
  /\bDROP\s+(?:TABLE|DATABASE|INDEX|VIEW)\b/i,
39277
40054
  /\bTRUNCATE\b/i,
39278
40055
  /\bALTER\s+TABLE\b/i,
@@ -39282,7 +40059,7 @@ var DANGEROUS_PATTERNS2 = [
39282
40059
  /\bCREATE\s+(?:TABLE|DATABASE|INDEX)\b/i
39283
40060
  ];
39284
40061
  function isDangerousSql(sql2) {
39285
- return DANGEROUS_PATTERNS2.some((pattern) => pattern.test(sql2));
40062
+ return DANGEROUS_PATTERNS.some((pattern) => pattern.test(sql2));
39286
40063
  }
39287
40064
  var sqlQueryTool = defineTool({
39288
40065
  name: "sql_query",
@@ -42725,7 +43502,20 @@ var ParallelToolExecutor = class {
42725
43502
  }
42726
43503
  nextTaskIndex++;
42727
43504
  activeCount++;
42728
- const taskPromise = this.executeSingleTool(
43505
+ const execPromise = options.hookRegistry && options.hookExecutor ? this.executeSingleToolWithHooks(
43506
+ task.toolCall,
43507
+ task.index,
43508
+ total,
43509
+ registry,
43510
+ options
43511
+ ).then(({ executed: executed2, skipped: wasSkipped, reason }) => {
43512
+ if (wasSkipped) {
43513
+ const skipReason = reason ?? "Blocked by hook";
43514
+ skipped.push({ toolCall: task.toolCall, reason: skipReason });
43515
+ onToolSkipped?.(task.toolCall, skipReason);
43516
+ }
43517
+ return executed2 ?? null;
43518
+ }) : this.executeSingleTool(
42729
43519
  task.toolCall,
42730
43520
  task.index,
42731
43521
  total,
@@ -42734,14 +43524,23 @@ var ParallelToolExecutor = class {
42734
43524
  onToolEnd,
42735
43525
  signal,
42736
43526
  onPathAccessDenied
42737
- ).then((result) => {
42738
- task.result = result;
42739
- task.completed = true;
42740
- results[task.index - 1] = result;
42741
- activeCount--;
42742
- startNextTask();
42743
- return result;
42744
- });
43527
+ );
43528
+ const taskPromise = execPromise.then(
43529
+ (result) => {
43530
+ task.result = result;
43531
+ task.completed = true;
43532
+ results[task.index - 1] = result;
43533
+ activeCount--;
43534
+ startNextTask();
43535
+ return result;
43536
+ },
43537
+ (err) => {
43538
+ task.completed = true;
43539
+ activeCount--;
43540
+ startNextTask();
43541
+ throw err;
43542
+ }
43543
+ );
42745
43544
  task.promise = taskPromise;
42746
43545
  processingPromises.push(taskPromise);
42747
43546
  }
@@ -42772,7 +43571,11 @@ var ParallelToolExecutor = class {
42772
43571
  if (!result.success && result.error && onPathAccessDenied) {
42773
43572
  const dirPath = extractDeniedPath(result.error);
42774
43573
  if (dirPath) {
42775
- const authorized = await onPathAccessDenied(dirPath);
43574
+ let authorized = false;
43575
+ try {
43576
+ authorized = await onPathAccessDenied(dirPath);
43577
+ } catch {
43578
+ }
42776
43579
  if (authorized) {
42777
43580
  result = await registry.execute(toolCall.name, toolCall.input, { signal });
42778
43581
  }
@@ -43060,7 +43863,13 @@ async function executeAgentTurn(session, userMessage, provider, toolRegistry, op
43060
43863
  const result = await promptAllowPath(dirPath);
43061
43864
  options.onAfterConfirmation?.();
43062
43865
  return result;
43063
- }
43866
+ },
43867
+ // Pass hooks through so PreToolUse/PostToolUse hooks fire during execution
43868
+ hookRegistry: options.hookRegistry,
43869
+ hookExecutor: options.hookExecutor,
43870
+ sessionId: session.id,
43871
+ projectPath: session.projectPath,
43872
+ onHookExecuted: options.onHookExecuted
43064
43873
  });
43065
43874
  for (const executed of parallelResult.executed) {
43066
43875
  executedTools.push(executed);
@@ -43132,6 +43941,16 @@ async function executeAgentTurn(session, userMessage, provider, toolRegistry, op
43132
43941
  content: executedCall.result.output,
43133
43942
  is_error: !executedCall.result.success
43134
43943
  });
43944
+ } else {
43945
+ console.warn(
43946
+ `[AgentLoop] No result found for tool call ${toolCall.name}:${toolCall.id} \u2014 injecting error placeholder to keep history valid`
43947
+ );
43948
+ toolResults.push({
43949
+ type: "tool_result",
43950
+ tool_use_id: toolCall.id,
43951
+ content: "Tool execution result unavailable (internal error)",
43952
+ is_error: true
43953
+ });
43135
43954
  }
43136
43955
  }
43137
43956
  let stuckInErrorLoop = false;
@@ -43509,15 +44328,24 @@ function createIntentRecognizer(config = {}) {
43509
44328
  raw: input
43510
44329
  };
43511
44330
  }
44331
+ if (!trimmedInput.startsWith("/")) {
44332
+ return {
44333
+ type: "chat",
44334
+ confidence: 1,
44335
+ entities: {},
44336
+ raw: input
44337
+ };
44338
+ }
44339
+ const commandPart = trimmedInput.slice(1);
43512
44340
  const intentTypes = ["status", "trust", "help", "exit"];
43513
44341
  let bestMatch = null;
43514
44342
  for (const type of intentTypes) {
43515
- const match = matchIntent(trimmedInput, type);
44343
+ const match = matchIntent(commandPart, type);
43516
44344
  if (match.matched && match.confidence > (bestMatch?.confidence || 0)) {
43517
44345
  bestMatch = {
43518
44346
  type,
43519
44347
  confidence: match.confidence,
43520
- entities: extractEntities(trimmedInput),
44348
+ entities: extractEntities(commandPart),
43521
44349
  raw: input,
43522
44350
  matchedPattern: match.pattern
43523
44351
  };
@@ -43528,7 +44356,7 @@ function createIntentRecognizer(config = {}) {
43528
44356
  return {
43529
44357
  type: "chat",
43530
44358
  confidence: bestMatch?.confidence || 0.3,
43531
- entities: extractEntities(trimmedInput),
44359
+ entities: extractEntities(commandPart),
43532
44360
  raw: input
43533
44361
  };
43534
44362
  }
@@ -43845,6 +44673,24 @@ async function startRepl(options = {}) {
43845
44673
  `[MCP] Initialization failed: ${mcpError instanceof Error ? mcpError.message : String(mcpError)}`
43846
44674
  );
43847
44675
  }
44676
+ let hookRegistry;
44677
+ let hookExecutor;
44678
+ try {
44679
+ const hooksConfigPath = `${projectPath}/.coco/hooks.json`;
44680
+ const { createHookRegistry: createHookRegistry2, createHookExecutor: createHookExecutor2 } = await Promise.resolve().then(() => (init_hooks(), hooks_exports));
44681
+ const registry = createHookRegistry2();
44682
+ await registry.loadFromFile(hooksConfigPath);
44683
+ if (registry.size > 0) {
44684
+ hookRegistry = registry;
44685
+ hookExecutor = createHookExecutor2();
44686
+ logger.info(`[Hooks] Loaded ${registry.size} hook(s) from ${hooksConfigPath}`);
44687
+ }
44688
+ } catch (hookError) {
44689
+ const msg = hookError instanceof Error ? hookError.message : String(hookError);
44690
+ if (!msg.includes("ENOENT")) {
44691
+ logger.warn(`[Hooks] Failed to load hooks: ${msg}`);
44692
+ }
44693
+ }
43848
44694
  const inputHandler = createInputHandler();
43849
44695
  const { createConcurrentCapture: createConcurrentCapture2 } = await Promise.resolve().then(() => (init_concurrent_capture_v2(), concurrent_capture_v2_exports));
43850
44696
  const { createFeedbackSystem: createFeedbackSystem2 } = await Promise.resolve().then(() => (init_feedback_system(), feedback_system_exports));
@@ -43903,6 +44749,10 @@ async function startRepl(options = {}) {
43903
44749
  break;
43904
44750
  }
43905
44751
  if (!input && !hasPendingImage()) continue;
44752
+ if (input && ["exit", "quit", "q"].includes(input.trim().toLowerCase())) {
44753
+ console.log(chalk25.dim("\nGoodbye!"));
44754
+ break;
44755
+ }
43906
44756
  let agentMessage = null;
43907
44757
  if (input && isSlashCommand(input)) {
43908
44758
  const prevProviderType = session.config.provider.type;
@@ -44128,6 +44978,8 @@ async function startRepl(options = {}) {
44128
44978
  );
44129
44979
  process.once("SIGINT", sigintHandler);
44130
44980
  let streamStarted = false;
44981
+ let llmCallCount = 0;
44982
+ let lastToolGroup = null;
44131
44983
  const result = await executeAgentTurn(session, effectiveMessage, provider, toolRegistry, {
44132
44984
  onStream: (chunk) => {
44133
44985
  if (!streamStarted) {
@@ -44155,10 +45007,13 @@ async function startRepl(options = {}) {
44155
45007
  }
44156
45008
  renderToolStart(result2.name, result2.input);
44157
45009
  renderToolEnd(result2);
45010
+ lastToolGroup = getToolGroup(result2.name);
44158
45011
  if (!result2.result.success && result2.result.error && looksLikeTechnicalJargon(result2.result.error)) {
44159
45012
  pendingExplanations.push(humanizeWithLLM(result2.result.error, result2.name, provider));
44160
45013
  }
44161
- if (isQualityLoop()) {
45014
+ if (isQualityLoop() && llmCallCount > 0) {
45015
+ setSpinner(`Processing results (iter. ${llmCallCount})...`);
45016
+ } else if (isQualityLoop()) {
44162
45017
  setSpinner("Processing results & checking quality...");
44163
45018
  } else {
44164
45019
  setSpinner("Processing...");
@@ -44169,18 +45024,22 @@ async function startRepl(options = {}) {
44169
45024
  console.log(chalk25.yellow(`\u2298 Skipped ${tc.name}: ${reason}`));
44170
45025
  },
44171
45026
  onThinkingStart: () => {
44172
- setSpinner("Thinking...");
45027
+ llmCallCount++;
45028
+ const iterPrefix = isQualityLoop() && llmCallCount > 1 ? `Iter. ${llmCallCount} \xB7 ` : "";
45029
+ const afterText = lastToolGroup ? `after ${lastToolGroup} \xB7 ` : "";
45030
+ setSpinner(`${iterPrefix}${afterText}Thinking...`);
44173
45031
  thinkingStartTime = Date.now();
44174
45032
  thinkingInterval = setInterval(() => {
44175
45033
  if (!thinkingStartTime) return;
44176
45034
  const elapsed = Math.floor((Date.now() - thinkingStartTime) / 1e3);
44177
45035
  if (elapsed < 4) return;
45036
+ const prefix = isQualityLoop() && llmCallCount > 1 ? `Iter. ${llmCallCount} \xB7 ` : "";
44178
45037
  if (isQualityLoop()) {
44179
- if (elapsed < 8) setSpinner("Analyzing request...");
44180
- else if (elapsed < 15) setSpinner("Running quality checks...");
44181
- else if (elapsed < 25) setSpinner("Iterating for quality...");
44182
- else if (elapsed < 40) setSpinner("Verifying implementation...");
44183
- else setSpinner(`Quality iteration in progress... (${elapsed}s)`);
45038
+ if (elapsed < 8) setSpinner(`${prefix}Analyzing results...`);
45039
+ else if (elapsed < 15) setSpinner(`${prefix}Running quality checks...`);
45040
+ else if (elapsed < 25) setSpinner(`${prefix}Iterating for quality...`);
45041
+ else if (elapsed < 40) setSpinner(`${prefix}Verifying implementation...`);
45042
+ else setSpinner(`${prefix}Still working... (${elapsed}s)`);
44184
45043
  } else {
44185
45044
  if (elapsed < 8) setSpinner("Analyzing request...");
44186
45045
  else if (elapsed < 12) setSpinner("Planning approach...");
@@ -44205,7 +45064,10 @@ async function startRepl(options = {}) {
44205
45064
  concurrentCapture.resumeCapture();
44206
45065
  inputEcho.resume();
44207
45066
  },
44208
- signal: abortController.signal
45067
+ signal: abortController.signal,
45068
+ // Wire lifecycle hooks (PreToolUse/PostToolUse) if configured in .coco/hooks.json
45069
+ hookRegistry,
45070
+ hookExecutor
44209
45071
  });
44210
45072
  clearThinkingInterval();
44211
45073
  clearSpinner();
@@ -44472,6 +45334,37 @@ async function checkProjectTrust(projectPath) {
44472
45334
  console.log(chalk25.green(" \u2713 Access granted") + chalk25.dim(" \u2022 /trust to manage"));
44473
45335
  return true;
44474
45336
  }
45337
+ function getToolGroup(toolName) {
45338
+ switch (toolName) {
45339
+ case "run_tests":
45340
+ return "running tests";
45341
+ case "bash_exec":
45342
+ return "running command";
45343
+ case "web_search":
45344
+ case "web_fetch":
45345
+ return "web search";
45346
+ case "read_file":
45347
+ case "list_directory":
45348
+ case "glob_files":
45349
+ case "tree":
45350
+ return "reading files";
45351
+ case "grep_search":
45352
+ case "semantic_search":
45353
+ case "codebase_map":
45354
+ return "searching code";
45355
+ case "write_file":
45356
+ return "writing file";
45357
+ case "edit_file":
45358
+ return "editing file";
45359
+ case "git_status":
45360
+ case "git_diff":
45361
+ case "git_commit":
45362
+ case "git_log":
45363
+ return "git";
45364
+ default:
45365
+ return toolName.replace(/_/g, " ");
45366
+ }
45367
+ }
44475
45368
  function getToolPreparingDescription(toolName) {
44476
45369
  switch (toolName) {
44477
45370
  case "write_file":