Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 14 additions & 14 deletions plugin/scripts/context-hook.js

Large diffs are not rendered by default.

24 changes: 12 additions & 12 deletions plugin/scripts/new-hook.js

Large diffs are not rendered by default.

28 changes: 14 additions & 14 deletions plugin/scripts/save-hook.js

Large diffs are not rendered by default.

22 changes: 11 additions & 11 deletions plugin/scripts/summary-hook.js

Large diffs are not rendered by default.

16 changes: 8 additions & 8 deletions plugin/scripts/user-message-hook.js

Large diffs are not rendered by default.

7 changes: 3 additions & 4 deletions src/hooks/context-hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
*/

import { stdin } from "process";
import { ensureWorkerRunning, getWorkerPort } from "../shared/worker-utils.js";
import { ensureWorkerRunning, getWorkerPort, fetchWithRetry } from "../shared/worker-utils.js";
import { HOOK_TIMEOUTS } from "../shared/hook-constants.js";
import { getProjectName } from "../utils/project-name.js";
import { logger } from "../utils/logger.js";
Expand All @@ -29,9 +29,8 @@ async function contextHook(input?: SessionStartInput): Promise<string> {

const url = `http://127.0.0.1:${port}/api/context/inject?project=${encodeURIComponent(project)}`;

// Note: Removed AbortSignal.timeout due to Windows Bun cleanup issue (libuv assertion)
// Worker service has its own timeouts, so client-side timeout is redundant
const response = await fetch(url);
// Uses fetchWithRetry to handle transient network errors like UND_ERR_SOCKET
const response = await fetchWithRetry(url, {});

if (!response.ok) {
throw new Error(`Context generation failed: ${response.status}`);
Expand Down
40 changes: 23 additions & 17 deletions src/hooks/new-hook.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { stdin } from 'process';
import { STANDARD_HOOK_RESPONSE } from './hook-response.js';
import { ensureWorkerRunning, getWorkerPort } from '../shared/worker-utils.js';
import { ensureWorkerRunning, getWorkerPort, fetchWithRetry } from '../shared/worker-utils.js';
import { getProjectName } from '../utils/project-name.js';
import { logger } from '../utils/logger.js';

Expand Down Expand Up @@ -32,16 +32,19 @@ async function newHook(input?: UserPromptSubmitInput): Promise<void> {
logger.info('HOOK', 'new-hook: Calling /api/sessions/init', { contentSessionId: session_id, project, prompt_length: prompt?.length });

// Initialize session via HTTP - handles DB operations and privacy checks
const initResponse = await fetch(`http://127.0.0.1:${port}/api/sessions/init`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contentSessionId: session_id,
project,
prompt
})
// Note: Removed signal to avoid Windows Bun cleanup issue (libuv assertion)
});
// Uses fetchWithRetry to handle transient network errors like ECONNRESET
const initResponse = await fetchWithRetry(
`http://127.0.0.1:${port}/api/sessions/init`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contentSessionId: session_id,
project,
prompt
})
}
);

if (!initResponse.ok) {
throw new Error(`Session initialization failed: ${initResponse.status}`);
Expand Down Expand Up @@ -69,12 +72,15 @@ async function newHook(input?: UserPromptSubmitInput): Promise<void> {
logger.info('HOOK', 'new-hook: Calling /sessions/{sessionDbId}/init', { sessionDbId, promptNumber, userPrompt_length: cleanedPrompt?.length });

// Initialize SDK agent session via HTTP (starts the agent!)
const response = await fetch(`http://127.0.0.1:${port}/sessions/${sessionDbId}/init`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userPrompt: cleanedPrompt, promptNumber })
// Note: Removed signal to avoid Windows Bun cleanup issue (libuv assertion)
});
// Uses fetchWithRetry to handle transient network errors like ECONNRESET
const response = await fetchWithRetry(
`http://127.0.0.1:${port}/sessions/${sessionDbId}/init`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userPrompt: cleanedPrompt, promptNumber })
}
);

if (!response.ok) {
throw new Error(`SDK agent start failed: ${response.status}`);
Expand Down
29 changes: 16 additions & 13 deletions src/hooks/save-hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import { stdin } from 'process';
import { STANDARD_HOOK_RESPONSE } from './hook-response.js';
import { logger } from '../utils/logger.js';
import { ensureWorkerRunning, getWorkerPort } from '../shared/worker-utils.js';
import { ensureWorkerRunning, getWorkerPort, fetchWithRetry } from '../shared/worker-utils.js';
import { HOOK_TIMEOUTS } from '../shared/hook-constants.js';

export interface PostToolUseInput {
Expand Down Expand Up @@ -47,18 +47,21 @@ async function saveHook(input?: PostToolUseInput): Promise<void> {
}

// Send to worker - worker handles privacy check and database operations
const response = await fetch(`http://127.0.0.1:${port}/api/sessions/observations`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contentSessionId: session_id,
tool_name,
tool_input,
tool_response,
cwd
})
// Note: Removed signal to avoid Windows Bun cleanup issue (libuv assertion)
});
// Uses fetchWithRetry to handle transient network errors like ECONNRESET
const response = await fetchWithRetry(
`http://127.0.0.1:${port}/api/sessions/observations`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contentSessionId: session_id,
tool_name,
tool_input,
tool_response,
cwd
})
}
);

if (!response.ok) {
throw new Error(`Observation storage failed: ${response.status}`);
Expand Down
23 changes: 13 additions & 10 deletions src/hooks/summary-hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
import { stdin } from 'process';
import { STANDARD_HOOK_RESPONSE } from './hook-response.js';
import { logger } from '../utils/logger.js';
import { ensureWorkerRunning, getWorkerPort } from '../shared/worker-utils.js';
import { ensureWorkerRunning, getWorkerPort, fetchWithRetry } from '../shared/worker-utils.js';
import { HOOK_TIMEOUTS } from '../shared/hook-constants.js';
import { extractLastMessage } from '../shared/transcript-parser.js';

Expand Down Expand Up @@ -53,15 +53,18 @@ async function summaryHook(input?: StopInput): Promise<void> {
});

// Send to worker - worker handles privacy check and database operations
const response = await fetch(`http://127.0.0.1:${port}/api/sessions/summarize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contentSessionId: session_id,
last_assistant_message: lastAssistantMessage
})
// Note: Removed signal to avoid Windows Bun cleanup issue (libuv assertion)
});
// Uses fetchWithRetry to handle transient network errors like ECONNRESET
const response = await fetchWithRetry(
`http://127.0.0.1:${port}/api/sessions/summarize`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contentSessionId: session_id,
last_assistant_message: lastAssistantMessage
})
}
);

if (!response.ok) {
console.log(STANDARD_HOOK_RESPONSE);
Expand Down
62 changes: 62 additions & 0 deletions src/shared/worker-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,68 @@ export function clearPortCache(): void {
cachedHost = null;
}

/**
* Fetch with retry logic for transient network errors.
* Handles ECONNRESET, ECONNREFUSED, ETIMEDOUT, and socket errors.
*
* With default maxRetries=3, performs up to 4 total attempts:
* - Initial attempt
* - Up to 3 retries with exponential backoff (100ms, 200ms, 400ms)
Comment on lines +64 to +65
Copy link

Copilot AI Jan 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documentation comment states "performs up to 4 total attempts" but this is misleading. With maxRetries=3, the loop runs from attempt=0 to attempt=3 (inclusive), which is indeed 4 attempts total. However, the comment then lists "Up to 3 retries with exponential backoff (100ms, 200ms, 400ms)" which only shows 3 delay values.

The actual delays for each retry attempt are:

  • Attempt 0: no delay (initial attempt)
  • Attempt 1 (first retry): ~100ms (100 * 2^0 = 100ms + jitter)
  • Attempt 2 (second retry): ~200ms (100 * 2^1 = 200ms + jitter)
  • Attempt 3 (third retry): ~400ms (100 * 2^2 = 400ms + jitter)

The comment should clarify that the initial attempt has no delay, and the delays shown are for the retry attempts only.

Suggested change
* - Initial attempt
* - Up to 3 retries with exponential backoff (100ms, 200ms, 400ms)
* - Initial attempt (no delay)
* - Up to 3 retries with exponential backoff between retries (~100ms, ~200ms, ~400ms)

Copilot uses AI. Check for mistakes.
*/
export async function fetchWithRetry(
url: string,
options: RequestInit,
config: {
maxRetries?: number;
initialDelayMs?: number;
maxDelayMs?: number;
} = {}
): Promise<Response> {
const {
maxRetries = 3,
initialDelayMs = 100,
maxDelayMs = 1000
} = config;
Comment on lines +76 to +80
Copy link

Copilot AI Jan 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documentation in the PR description states "Retries up to 3 times" and "exponential backoff (100ms → 200ms → 400ms)", but the current implementation doesn't match this specification. With maxRetries = 3 and the loop condition attempt <= maxRetries, this results in 4 total attempts (attempt 0, 1, 2, 3), not 3 retries. The delays would be approximately: 100ms, 300ms, 700ms, and potentially 1000ms (capped). To match the documentation, either update the loop condition to attempt < maxRetries or clarify the documentation to reflect "up to 4 attempts" behavior.

Copilot uses AI. Check for mistakes.

const retryableErrors = ['ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'UND_ERR_SOCKET'];

for (let attempt = 0; attempt <= maxRetries; attempt++) {
Copy link

Copilot AI Jan 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The loop condition allows attempt to equal maxRetries, which means the function actually performs maxRetries + 1 attempts instead of the expected maxRetries attempts. For example, with maxRetries = 3, the loop iterates with attempt = 0, 1, 2, 3, resulting in 4 total attempts (1 initial + 3 retries).

The condition should be attempt < maxRetries to perform exactly 3 retries as documented and expected.

Copilot uses AI. Check for mistakes.
try {
return await fetch(url, options);
} catch (error) {
const errorCode = (error as any)?.cause?.code || '';
const errorMessage = (error as Error).message || '';

const isRetryable = retryableErrors.some(code =>
errorCode.includes(code) || errorMessage.includes(code)
Copy link

Copilot AI Jan 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error checking logic uses string inclusion checks on both error codes and error messages. This could lead to false positives. For example, if an error message happens to contain the word "ECONNRESET" in a different context (e.g., "Connection failed (not ECONNRESET)"), it would be treated as retryable.

Consider checking the error code more strictly by exact matching rather than inclusion, as error codes are typically standardized values. The error message check could remain as an inclusion check as a fallback.

Suggested change
errorCode.includes(code) || errorMessage.includes(code)
errorCode === code || errorMessage.includes(code)

Copilot uses AI. Check for mistakes.
);

// Throw immediately for non-retryable errors or if we've exhausted retries
if (!isRetryable || attempt === maxRetries) {
throw error;
}

// Exponential backoff with jitter
const delay = Math.min(
initialDelayMs * Math.pow(2, attempt) + Math.random() * 50,
maxDelayMs
);

logger.debug('SYSTEM', 'Fetch failed, retrying', {
attempt: attempt + 1,
maxRetries,
delay: Math.round(delay),
error: errorMessage
});

await new Promise(r => setTimeout(r, delay));
}
}

// This line is unreachable but satisfies TypeScript's control flow analysis
throw new Error('Unexpected: exhausted retries without throwing');
}

/**
* Check if worker is responsive and fully initialized by trying the readiness endpoint
* Changed from /health to /api/readiness to ensure MCP initialization is complete
Expand Down