@corbat-tech/coco 2.5.2 → 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
@@ -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
@@ -1650,6 +1664,30 @@ var init_openai = __esm({
1650
1664
  }
1651
1665
  };
1652
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
+ };
1653
1691
  try {
1654
1692
  for await (const chunk of stream) {
1655
1693
  const delta = chunk.choices[0]?.delta;
@@ -1661,7 +1699,7 @@ var init_openai = __esm({
1661
1699
  }
1662
1700
  if (delta?.tool_calls) {
1663
1701
  for (const toolCallDelta of delta.tool_calls) {
1664
- const index = toolCallDelta.index;
1702
+ const index = toolCallDelta.index ?? toolCallBuilders.size;
1665
1703
  if (!toolCallBuilders.has(index)) {
1666
1704
  toolCallBuilders.set(index, {
1667
1705
  id: toolCallDelta.id ?? "",
@@ -1696,34 +1734,28 @@ var init_openai = __esm({
1696
1734
  }
1697
1735
  }
1698
1736
  }
1699
- }
1700
- for (const [, builder] of toolCallBuilders) {
1701
- let input = {};
1702
- try {
1703
- input = builder.arguments ? JSON.parse(builder.arguments) : {};
1704
- } catch (error) {
1705
- console.warn(
1706
- `[${this.name}] Failed to parse tool call arguments for ${builder.name}: ${builder.arguments?.slice(0, 300)}`
1707
- );
1708
- try {
1709
- if (builder.arguments) {
1710
- const repaired = jsonrepair(builder.arguments);
1711
- input = JSON.parse(repaired);
1712
- console.log(`[${this.name}] \u2713 Successfully repaired JSON for ${builder.name}`);
1713
- }
1714
- } catch {
1715
- console.error(
1716
- `[${this.name}] Cannot repair JSON for ${builder.name}, using empty object`
1717
- );
1718
- 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
+ };
1719
1748
  }
1749
+ toolCallBuilders.clear();
1720
1750
  }
1751
+ }
1752
+ for (const [, builder] of toolCallBuilders) {
1721
1753
  yield {
1722
1754
  type: "tool_use_end",
1723
1755
  toolCall: {
1724
1756
  id: builder.id,
1725
1757
  name: builder.name,
1726
- input
1758
+ input: parseArguments(builder)
1727
1759
  }
1728
1760
  };
1729
1761
  }
@@ -1892,7 +1924,8 @@ var init_openai = __esm({
1892
1924
  if (msg.role === "system") {
1893
1925
  result.push({ role: "system", content: this.contentToString(msg.content) });
1894
1926
  } else if (msg.role === "user") {
1895
- 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 = [];
1896
1929
  for (const block of msg.content) {
1897
1930
  if (block.type === "tool_result") {
1898
1931
  const toolResult = block;
@@ -1901,8 +1934,16 @@ var init_openai = __esm({
1901
1934
  tool_call_id: toolResult.tool_use_id,
1902
1935
  content: toolResult.content
1903
1936
  });
1937
+ } else if (block.type === "text") {
1938
+ textParts.push(block.text);
1904
1939
  }
1905
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
+ }
1906
1947
  } else if (Array.isArray(msg.content) && msg.content.some((b) => b.type === "image")) {
1907
1948
  const parts = [];
1908
1949
  for (const block of msg.content) {
@@ -1947,6 +1988,10 @@ var init_openai = __esm({
1947
1988
  arguments: JSON.stringify(block.input)
1948
1989
  }
1949
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
+ );
1950
1995
  }
1951
1996
  }
1952
1997
  if (textParts.length > 0) {
@@ -6526,8 +6571,18 @@ CONVERSATION:
6526
6571
  wasCompacted: false
6527
6572
  };
6528
6573
  }
6529
- const messagesToSummarize = conversationMessages.slice(0, -this.config.preserveLastN);
6530
- 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);
6531
6586
  if (messagesToSummarize.length === 0) {
6532
6587
  return {
6533
6588
  messages,
@@ -8768,8 +8823,8 @@ async function killOrphanedTestProcesses() {
8768
8823
  }
8769
8824
  let killed = 0;
8770
8825
  try {
8771
- const { execa: execa13 } = await import('execa');
8772
- 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"], {
8773
8828
  reject: false
8774
8829
  });
8775
8830
  const pids = result.stdout.split("\n").map((s) => parseInt(s.trim(), 10)).filter((pid) => !isNaN(pid) && pid !== process.pid && pid !== process.ppid);
@@ -14330,6 +14385,15 @@ __export(bash_exports, {
14330
14385
  commandExistsTool: () => commandExistsTool,
14331
14386
  getEnvTool: () => getEnvTool
14332
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
+ }
14333
14397
  function isEnvVarSafe(name) {
14334
14398
  if (SAFE_ENV_VARS.has(name)) {
14335
14399
  return true;
@@ -14350,14 +14414,14 @@ function truncateOutput(output, maxLength = 5e4) {
14350
14414
 
14351
14415
  [Output truncated - ${output.length - maxLength} more characters]`;
14352
14416
  }
14353
- 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;
14354
14418
  var init_bash = __esm({
14355
14419
  "src/tools/bash.ts"() {
14356
14420
  init_registry4();
14357
14421
  init_errors();
14358
14422
  DEFAULT_TIMEOUT_MS = 12e4;
14359
14423
  MAX_OUTPUT_SIZE = 1024 * 1024;
14360
- DANGEROUS_PATTERNS = [
14424
+ DANGEROUS_PATTERNS_FULL = [
14361
14425
  /\brm\s+-rf\s+\/(?!\w)/,
14362
14426
  // rm -rf / (root)
14363
14427
  /\bsudo\s+rm\s+-rf/,
@@ -14370,14 +14434,6 @@ var init_bash = __esm({
14370
14434
  // Format filesystem
14371
14435
  /\bformat\s+/,
14372
14436
  // Windows format
14373
- /`[^`]+`/,
14374
- // Backtick command substitution
14375
- /\$\([^)]+\)/,
14376
- // $() command substitution
14377
- /\beval\s+/,
14378
- // eval command
14379
- /\bsource\s+/,
14380
- // source command (can execute arbitrary scripts)
14381
14437
  />\s*\/etc\//,
14382
14438
  // Write to /etc
14383
14439
  />\s*\/root\//,
@@ -14391,6 +14447,16 @@ var init_bash = __esm({
14391
14447
  /\bwget\s+.*\|\s*(ba)?sh/
14392
14448
  // wget | sh pattern
14393
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
+ ];
14394
14460
  SAFE_ENV_VARS = /* @__PURE__ */ new Set([
14395
14461
  // System info (non-sensitive)
14396
14462
  "PATH",
@@ -14457,13 +14523,21 @@ Examples:
14457
14523
  env: z.record(z.string(), z.string()).optional().describe("Environment variables")
14458
14524
  }),
14459
14525
  async execute({ command, cwd, timeout, env: env2 }) {
14460
- for (const pattern of DANGEROUS_PATTERNS) {
14526
+ const shellPart = getShellCommandPart(command);
14527
+ for (const pattern of DANGEROUS_PATTERNS_FULL) {
14461
14528
  if (pattern.test(command)) {
14462
14529
  throw new ToolError(`Potentially dangerous command blocked: ${command.slice(0, 100)}`, {
14463
14530
  tool: "bash_exec"
14464
14531
  });
14465
14532
  }
14466
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
+ }
14467
14541
  const startTime = performance.now();
14468
14542
  const timeoutMs = timeout ?? DEFAULT_TIMEOUT_MS;
14469
14543
  const { CommandHeartbeat: CommandHeartbeat2 } = await Promise.resolve().then(() => (init_heartbeat(), heartbeat_exports));
@@ -14545,13 +14619,21 @@ Examples:
14545
14619
  env: z.record(z.string(), z.string()).optional().describe("Environment variables")
14546
14620
  }),
14547
14621
  async execute({ command, cwd, env: env2 }) {
14548
- for (const pattern of DANGEROUS_PATTERNS) {
14622
+ const shellPart = getShellCommandPart(command);
14623
+ for (const pattern of DANGEROUS_PATTERNS_FULL) {
14549
14624
  if (pattern.test(command)) {
14550
14625
  throw new ToolError(`Potentially dangerous command blocked: ${command.slice(0, 100)}`, {
14551
14626
  tool: "bash_background"
14552
14627
  });
14553
14628
  }
14554
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
+ }
14555
14637
  try {
14556
14638
  const filteredEnv = {};
14557
14639
  for (const [key, value] of Object.entries(process.env)) {
@@ -18787,6 +18869,680 @@ var init_tools = __esm({
18787
18869
  }
18788
18870
  });
18789
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
+
18790
19546
  // src/cli/repl/input/message-queue.ts
18791
19547
  function createMessageQueue(maxSize = 50) {
18792
19548
  let messages = [];
@@ -23100,9 +23856,9 @@ async function createCliPhaseContext(projectPath, _onUserInput) {
23100
23856
  },
23101
23857
  bash: {
23102
23858
  async exec(command, options = {}) {
23103
- const { execa: execa13 } = await import('execa');
23859
+ const { execa: execa14 } = await import('execa');
23104
23860
  try {
23105
- const result = await execa13(command, {
23861
+ const result = await execa14(command, {
23106
23862
  shell: true,
23107
23863
  cwd: options.cwd || projectPath,
23108
23864
  timeout: options.timeout,
@@ -31847,10 +32603,10 @@ var resumeCommand = {
31847
32603
 
31848
32604
  // src/cli/repl/version-check.ts
31849
32605
  init_version();
31850
- var NPM_REGISTRY_URL = "https://registry.npmjs.org/@corbat-tech/coco";
32606
+ var NPM_REGISTRY_URL = "https://registry.npmjs.org/@corbat-tech/coco/latest";
31851
32607
  var CHECK_INTERVAL_MS = 24 * 60 * 60 * 1e3;
31852
- var FETCH_TIMEOUT_MS = 2e3;
31853
- var STARTUP_TIMEOUT_MS = 2500;
32608
+ var FETCH_TIMEOUT_MS = 5e3;
32609
+ var STARTUP_TIMEOUT_MS = 5500;
31854
32610
  var CACHE_DIR = path34__default.join(os4__default.homedir(), ".coco");
31855
32611
  var CACHE_FILE = path34__default.join(CACHE_DIR, "version-check-cache.json");
31856
32612
  function compareVersions(a, b) {
@@ -31898,7 +32654,7 @@ async function fetchLatestVersion() {
31898
32654
  return null;
31899
32655
  }
31900
32656
  const data = await response.json();
31901
- return data["dist-tags"]?.latest ?? null;
32657
+ return data.version ?? null;
31902
32658
  } finally {
31903
32659
  clearTimeout(timeout);
31904
32660
  }
@@ -31970,10 +32726,14 @@ function printUpdateBanner(updateInfo) {
31970
32726
  console.log();
31971
32727
  }
31972
32728
  async function checkForUpdatesInteractive() {
32729
+ let startupTimerId;
31973
32730
  const updateInfo = await Promise.race([
31974
32731
  checkForUpdates(),
31975
- new Promise((resolve4) => setTimeout(() => resolve4(null), STARTUP_TIMEOUT_MS))
32732
+ new Promise((resolve4) => {
32733
+ startupTimerId = setTimeout(() => resolve4(null), STARTUP_TIMEOUT_MS);
32734
+ })
31976
32735
  ]);
32736
+ clearTimeout(startupTimerId);
31977
32737
  if (!updateInfo) return;
31978
32738
  const p45 = await import('@clack/prompts');
31979
32739
  printUpdateBanner(updateInfo);
@@ -31986,10 +32746,10 @@ async function checkForUpdatesInteractive() {
31986
32746
  console.log(chalk25.dim(` Running: ${updateInfo.updateCommand}`));
31987
32747
  console.log();
31988
32748
  try {
31989
- const { execa: execa13 } = await import('execa');
32749
+ const { execa: execa14 } = await import('execa');
31990
32750
  const [cmd, ...args] = updateInfo.updateCommand.split(" ");
31991
32751
  if (!cmd) return;
31992
- await execa13(cmd, args, { stdio: "inherit", timeout: 12e4 });
32752
+ await execa14(cmd, args, { stdio: "inherit", timeout: 12e4 });
31993
32753
  console.log();
31994
32754
  console.log(chalk25.green(" \u2713 Updated! Run coco again to start the new version."));
31995
32755
  console.log();
@@ -39289,7 +40049,7 @@ var imageTools = [readImageTool];
39289
40049
  init_registry4();
39290
40050
  init_errors();
39291
40051
  var path43 = await import('path');
39292
- var DANGEROUS_PATTERNS2 = [
40052
+ var DANGEROUS_PATTERNS = [
39293
40053
  /\bDROP\s+(?:TABLE|DATABASE|INDEX|VIEW)\b/i,
39294
40054
  /\bTRUNCATE\b/i,
39295
40055
  /\bALTER\s+TABLE\b/i,
@@ -39299,7 +40059,7 @@ var DANGEROUS_PATTERNS2 = [
39299
40059
  /\bCREATE\s+(?:TABLE|DATABASE|INDEX)\b/i
39300
40060
  ];
39301
40061
  function isDangerousSql(sql2) {
39302
- return DANGEROUS_PATTERNS2.some((pattern) => pattern.test(sql2));
40062
+ return DANGEROUS_PATTERNS.some((pattern) => pattern.test(sql2));
39303
40063
  }
39304
40064
  var sqlQueryTool = defineTool({
39305
40065
  name: "sql_query",
@@ -42742,7 +43502,20 @@ var ParallelToolExecutor = class {
42742
43502
  }
42743
43503
  nextTaskIndex++;
42744
43504
  activeCount++;
42745
- 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(
42746
43519
  task.toolCall,
42747
43520
  task.index,
42748
43521
  total,
@@ -42751,14 +43524,23 @@ var ParallelToolExecutor = class {
42751
43524
  onToolEnd,
42752
43525
  signal,
42753
43526
  onPathAccessDenied
42754
- ).then((result) => {
42755
- task.result = result;
42756
- task.completed = true;
42757
- results[task.index - 1] = result;
42758
- activeCount--;
42759
- startNextTask();
42760
- return result;
42761
- });
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
+ );
42762
43544
  task.promise = taskPromise;
42763
43545
  processingPromises.push(taskPromise);
42764
43546
  }
@@ -42789,7 +43571,11 @@ var ParallelToolExecutor = class {
42789
43571
  if (!result.success && result.error && onPathAccessDenied) {
42790
43572
  const dirPath = extractDeniedPath(result.error);
42791
43573
  if (dirPath) {
42792
- const authorized = await onPathAccessDenied(dirPath);
43574
+ let authorized = false;
43575
+ try {
43576
+ authorized = await onPathAccessDenied(dirPath);
43577
+ } catch {
43578
+ }
42793
43579
  if (authorized) {
42794
43580
  result = await registry.execute(toolCall.name, toolCall.input, { signal });
42795
43581
  }
@@ -43077,7 +43863,13 @@ async function executeAgentTurn(session, userMessage, provider, toolRegistry, op
43077
43863
  const result = await promptAllowPath(dirPath);
43078
43864
  options.onAfterConfirmation?.();
43079
43865
  return result;
43080
- }
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
43081
43873
  });
43082
43874
  for (const executed of parallelResult.executed) {
43083
43875
  executedTools.push(executed);
@@ -43149,6 +43941,16 @@ async function executeAgentTurn(session, userMessage, provider, toolRegistry, op
43149
43941
  content: executedCall.result.output,
43150
43942
  is_error: !executedCall.result.success
43151
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
+ });
43152
43954
  }
43153
43955
  }
43154
43956
  let stuckInErrorLoop = false;
@@ -43871,6 +44673,24 @@ async function startRepl(options = {}) {
43871
44673
  `[MCP] Initialization failed: ${mcpError instanceof Error ? mcpError.message : String(mcpError)}`
43872
44674
  );
43873
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
+ }
43874
44694
  const inputHandler = createInputHandler();
43875
44695
  const { createConcurrentCapture: createConcurrentCapture2 } = await Promise.resolve().then(() => (init_concurrent_capture_v2(), concurrent_capture_v2_exports));
43876
44696
  const { createFeedbackSystem: createFeedbackSystem2 } = await Promise.resolve().then(() => (init_feedback_system(), feedback_system_exports));
@@ -43929,6 +44749,10 @@ async function startRepl(options = {}) {
43929
44749
  break;
43930
44750
  }
43931
44751
  if (!input && !hasPendingImage()) continue;
44752
+ if (input && ["exit", "quit", "q"].includes(input.trim().toLowerCase())) {
44753
+ console.log(chalk25.dim("\nGoodbye!"));
44754
+ break;
44755
+ }
43932
44756
  let agentMessage = null;
43933
44757
  if (input && isSlashCommand(input)) {
43934
44758
  const prevProviderType = session.config.provider.type;
@@ -44154,6 +44978,8 @@ async function startRepl(options = {}) {
44154
44978
  );
44155
44979
  process.once("SIGINT", sigintHandler);
44156
44980
  let streamStarted = false;
44981
+ let llmCallCount = 0;
44982
+ let lastToolGroup = null;
44157
44983
  const result = await executeAgentTurn(session, effectiveMessage, provider, toolRegistry, {
44158
44984
  onStream: (chunk) => {
44159
44985
  if (!streamStarted) {
@@ -44181,10 +45007,13 @@ async function startRepl(options = {}) {
44181
45007
  }
44182
45008
  renderToolStart(result2.name, result2.input);
44183
45009
  renderToolEnd(result2);
45010
+ lastToolGroup = getToolGroup(result2.name);
44184
45011
  if (!result2.result.success && result2.result.error && looksLikeTechnicalJargon(result2.result.error)) {
44185
45012
  pendingExplanations.push(humanizeWithLLM(result2.result.error, result2.name, provider));
44186
45013
  }
44187
- if (isQualityLoop()) {
45014
+ if (isQualityLoop() && llmCallCount > 0) {
45015
+ setSpinner(`Processing results (iter. ${llmCallCount})...`);
45016
+ } else if (isQualityLoop()) {
44188
45017
  setSpinner("Processing results & checking quality...");
44189
45018
  } else {
44190
45019
  setSpinner("Processing...");
@@ -44195,18 +45024,22 @@ async function startRepl(options = {}) {
44195
45024
  console.log(chalk25.yellow(`\u2298 Skipped ${tc.name}: ${reason}`));
44196
45025
  },
44197
45026
  onThinkingStart: () => {
44198
- 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...`);
44199
45031
  thinkingStartTime = Date.now();
44200
45032
  thinkingInterval = setInterval(() => {
44201
45033
  if (!thinkingStartTime) return;
44202
45034
  const elapsed = Math.floor((Date.now() - thinkingStartTime) / 1e3);
44203
45035
  if (elapsed < 4) return;
45036
+ const prefix = isQualityLoop() && llmCallCount > 1 ? `Iter. ${llmCallCount} \xB7 ` : "";
44204
45037
  if (isQualityLoop()) {
44205
- if (elapsed < 8) setSpinner("Analyzing request...");
44206
- else if (elapsed < 15) setSpinner("Running quality checks...");
44207
- else if (elapsed < 25) setSpinner("Iterating for quality...");
44208
- else if (elapsed < 40) setSpinner("Verifying implementation...");
44209
- 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)`);
44210
45043
  } else {
44211
45044
  if (elapsed < 8) setSpinner("Analyzing request...");
44212
45045
  else if (elapsed < 12) setSpinner("Planning approach...");
@@ -44231,7 +45064,10 @@ async function startRepl(options = {}) {
44231
45064
  concurrentCapture.resumeCapture();
44232
45065
  inputEcho.resume();
44233
45066
  },
44234
- signal: abortController.signal
45067
+ signal: abortController.signal,
45068
+ // Wire lifecycle hooks (PreToolUse/PostToolUse) if configured in .coco/hooks.json
45069
+ hookRegistry,
45070
+ hookExecutor
44235
45071
  });
44236
45072
  clearThinkingInterval();
44237
45073
  clearSpinner();
@@ -44498,6 +45334,37 @@ async function checkProjectTrust(projectPath) {
44498
45334
  console.log(chalk25.green(" \u2713 Access granted") + chalk25.dim(" \u2022 /trust to manage"));
44499
45335
  return true;
44500
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
+ }
44501
45368
  function getToolPreparingDescription(toolName) {
44502
45369
  switch (toolName) {
44503
45370
  case "write_file":