Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .nvmrc
Original file line number Diff line number Diff line change
@@ -1 +1 @@
v24.13.1
v26.3.1
3 changes: 3 additions & 0 deletions apps/claude-sdk-cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Customize which commands ExecV3 will run or refuse, without a mistake in that customization ever disabling safety or breaking the rest of your settings
- Decode escape sequences in --prompt values: \n, \r, \t, \\
- Display server tool use as its own block in the conversation
- Each connection logs the HTTP protocol it negotiated
- ESC while a tool is running cancels the tool instead of the query, so Claude receives the cancellation and can continue
- F3 opens a conversation view listing every conversation held in the current directory, with its model, cost, query and turn counts, context use, span, opening ask and last reply; space peeks at the tail of a conversation and enter switches to it in place, without restarting the CLI
- Flash tool approval prompt with inverted colours when awaiting Y/N
Expand All @@ -61,6 +62,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Inject a skill-catalogue delta: re-scan the skill roots each query and prepend a system-reminder naming the skills whose SKILL.md content changed, silent on the first scan of a session and after a resume
- Inject the available-skills catalogue as a cached system-reminder on the first user message, scanned from skillDirs at startup and re-injected after compaction, so the model can discover skills to load
- Mark model with * suffix in status bar when overridden via --model
- New http.allowH2 setting, off by default, so API requests negotiate HTTP/1.1
- Publish conversation activity as opt-in NATS tap events
- Publish the agent concern: ready/pulse/attached/detached telemetry and service/drain/chdir requests
- Ref and PreviewEdit state is now persisted to disk
Expand Down Expand Up @@ -139,6 +141,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- A tool call refused without a prompt now says what refused it: the permission setting that decided, the operation it judged, and the paths that selected that setting. It previously reported only that the tool 'is configured to be denied automatically', which was untrue of every case and left both Claude and the operator guessing at a decision the CLI had already made
- Add `typescript` as a production dependency so consumers do not need it installed separately
- An error written to the log keeps its name, message, stack and cause instead of rendering as an empty object
- Apply biome formatting fixes
- Attachments added while a query is streaming are no longer cleared once that query finishes
- Count tool approval wait time as tool time in the status-line clock
Expand Down
3 changes: 3 additions & 0 deletions apps/claude-sdk-cli/changes.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -175,3 +175,6 @@
{"description":"Deleting a symlink inside the scratchpad is now approved. Removing a link never touches what it points at, so judging the delete by its destination made any link Claude created in its own scratchpad permanently undeletable. Writes still follow a link to where they land, and a delete whose parent directory resolves outside the scratchpad is still refused","category":"fixed"}
{"description":"Render markdown tables in a response, honouring column alignment","category":"added"}
{"description":"Fix a fenced code block drawing its border in the wrong place when it holds a link or a wide character","category":"fixed"}
{"description":"New http.allowH2 setting, off by default, so API requests negotiate HTTP/1.1","category":"added"}
{"description":"An error written to the log keeps its name, message, stack and cause instead of rendering as an empty object","category":"fixed"}
{"description":"Each connection logs the HTTP protocol it negotiated","category":"added"}
3 changes: 2 additions & 1 deletion apps/claude-sdk-cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
"@shellicar/build-version": "^2.0.0",
"@shellicar/typescript-config": "workspace:^",
"@tsconfig/node24": "^24.0.4",
"@types/node": "^25.9.5",
"@types/node": "^26.2.0",
"ajv": "^8.20.0",
"esbuild": "^0.28.0",
"tsx": "^4.22.5",
Expand All @@ -68,6 +68,7 @@
"marked": "^18.0.7",
"string-width": "^8.2.2",
"typescript": "^5.9.3",
"undici": "^8.10.0",
"winston": "^3.19.0",
"yaml": "^2.9.0",
"zod": "^4.4.3"
Expand Down
9 changes: 9 additions & 0 deletions apps/claude-sdk-cli/src/cli-config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,14 @@ const preventSleepSchema = z
.default({ enabled: true, platforms: { macos: 'caffeinate', windows: null, linux: null } })
.catch({ enabled: true, platforms: { macos: 'caffeinate', windows: null, linux: null } });

const httpSchema = z
.object({
allowH2: z.boolean().optional().default(false).catch(false).describe('Allow HTTP/2 for API requests. Off by default, so requests negotiate HTTP/1.1.'),
})
.optional()
.default({ allowH2: false })
.catch({ allowH2: false });

const secretsSchema = z
.object({
stripGhCredentials: z
Expand Down Expand Up @@ -392,6 +400,7 @@ export const sdkConfigSchema = z
permissions: permissionsSchema.describe('Tool approval permission matrix'),
workspace: workspaceSchema.describe('Scratchpad directory configuration'),
preventSleep: preventSleepSchema.describe('Sleep prevention during in-flight network requests'),
http: httpSchema.describe('HTTP transport configuration'),
persistence: persistenceSchema.describe('Persistence (SQLite) configuration'),
markdown: markdownSchema.describe('Markdown rendering configuration'),
memory: memorySchema.describe('Persistent memory configuration'),
Expand Down
25 changes: 23 additions & 2 deletions apps/claude-sdk-cli/src/logger.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import winston from 'winston';
import { redact } from './redact';
import { isPlainObject, redact } from './redact';

const levels = { error: 0, warn: 1, info: 2, debug: 3, trace: 4 };
const colors = { error: 'red', warn: 'yellow', info: 'green', debug: 'blue', trace: 'gray' };
Expand Down Expand Up @@ -55,9 +55,30 @@ const winstonLogger = winston.createLogger({
transports,
}) as winston.Logger & { trace: winston.LeveledLogMethod };

// An Error's name, message, stack and cause are non-enumerable, so JSON.stringify
// renders one as `{}`. Flatten it to a plain object before it reaches the format.
const serialiseErrors = (value: unknown): unknown => {
if (value instanceof Error) {
return {
...(serialiseErrors({ ...value }) as object),
name: value.name,
message: value.message,
stack: value.stack,
cause: serialiseErrors(value.cause),
};
}
if (Array.isArray(value)) {
return value.map(serialiseErrors);
}
if (isPlainObject(value)) {
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, serialiseErrors(v)]));
}
return value;
};

const wrapMeta = (meta: unknown[]): object => {
const wrapped = meta.length === 0 ? {} : meta.length === 1 ? { data: meta[0] } : { data: meta };
return redact(wrapped) as object;
return redact(serialiseErrors(wrapped)) as object;
};

export const logger = {
Expand Down
2 changes: 1 addition & 1 deletion apps/claude-sdk-cli/src/redact.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
const SENSITIVE_KEYS = new Set(['authorization', 'x-api-key', 'api-key', 'api_key', 'apikey', 'password', 'secret', 'token']);

const isPlainObject = (value: unknown): value is Record<string, unknown> => {
export const isPlainObject = (value: unknown): value is Record<string, unknown> => {
if (value === null || typeof value !== 'object') {
return false;
}
Expand Down
3 changes: 2 additions & 1 deletion apps/claude-sdk-cli/src/setup/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ import { ConversationSwitcher, IConversationSwitcher } from './ConversationSwitc
import { CwdTracker } from './CwdTracker.js';
import { DurableConfigFactory } from './DurableConfigFactory.js';
import { GitMemoryEnvironmentProvider } from './GitMemoryEnvironmentProvider.js';
import { createHttpDispatcher } from './httpDispatcher.js';
import { IRuntimeOptions } from './IRuntimeOptions.js';
import { ModelOverrides } from './ModelOverrides.js';
import { SdkChannel } from './SdkChannel.js';
Expand Down Expand Up @@ -399,7 +400,7 @@ export function buildContainer(options: ContainerOptions): IServiceCollection {
services.register(LoginFlow).as(ILoginFlow);
services
.register(AnthropicClient)
.using([ICredentialProvider, ILogger], (credentials, log) => new AnthropicClient(credentials, log))
.using([ICredentialProvider, ILogger, ConfigLoader], (credentials, log, loader) => new AnthropicClient(credentials, log, createHttpDispatcher(loader.config.http.allowH2, log)))
.as(IMessageStreamer);
services
.register(ModelCatalog)
Expand Down
14 changes: 14 additions & 0 deletions apps/claude-sdk-cli/src/setup/httpDispatcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import diagnostics_channel from 'node:diagnostics_channel';
import type { ILogger } from '@shellicar/claude-core/logging/ILogger';
import { Agent } from 'undici';

type ConnectedEvent = { socket?: { alpnProtocol?: string | false; servername?: string } };

/** The negotiated protocol is not otherwise observable, so each connection logs it. */
export const createHttpDispatcher = (allowH2: boolean, logger: ILogger): Agent => {
diagnostics_channel.subscribe('undici:client:connected', (event) => {
const socket = (event as ConnectedEvent)?.socket;
logger.info('connection established', { alpn: socket?.alpnProtocol ?? null, host: socket?.servername ?? null, allowH2 });
});
return new Agent({ allowH2 });
};
1 change: 1 addition & 0 deletions apps/claude-sdk-cli/test/cli-config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ describe('sdkConfigSchema', () => {
},
workspace: { enabled: true },
preventSleep: { enabled: true, platforms: { macos: 'caffeinate', windows: null, linux: null } },
http: { allowH2: false },
persistence: { database: 'persistence.db' },
markdown: { enabled: true, streaming: true },
memory: { tenantId: null, environment: {}, git: { enabled: true } },
Expand Down
2 changes: 1 addition & 1 deletion packages/claude-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
"@shellicar/build-version": "^2.0.0",
"@shellicar/typescript-config": "workspace:^",
"@tsconfig/node24": "^24.0.4",
"@types/node": "^25.9.5",
"@types/node": "^26.2.0",
"tsup": "^8.5.1",
"tsx": "^4.22.5",
"typescript": "^5.9.3"
Expand Down
2 changes: 1 addition & 1 deletion packages/claude-sdk-tools/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -373,7 +373,7 @@
"@shellicar/build-version": "^2.0.0",
"@shellicar/typescript-config": "workspace:^",
"@tsconfig/node24": "^24.0.4",
"@types/node": "^25.9.5",
"@types/node": "^26.2.0",
"esbuild": "^0.28.0",
"tsup": "^8.5.1",
"tsx": "^4.22.5",
Expand Down
2 changes: 2 additions & 0 deletions packages/claude-sdk/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Add support for Claude Opus 4.8
- Add the 'escalate' tool operation: a tool that crosses a privilege boundary always prompts for approval, independent of the read/write/delete cwd-zone matrix or any auto-approve config
- Add updateIdentityBody to the durable config provider, folding a live system-identity body in as the first system prompt on the next config read
- AnthropicClient accepts an undici Dispatcher, so a consumer chooses the HTTP protocol its requests negotiate
- Carry the request delta and its message, turn, and query ids through the final_message event, so the CLI can record each turn as a user/assistant pair
- Classify a mid-stream connection drop and retry it on a bounded fixed schedule instead of surfacing it as a fatal error, with injection seams to hold a wake lock and signal a reconnect
- Deliver tool attachments as native content blocks inside tool results
Expand All @@ -29,6 +30,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Emit tool_exec_start and tool_exec_end around tool execution, bracketing the run phase (approval waits included) so a consumer can frame and time it separately from tool-call generation
- ESC while a tool is running cancels the tool and delivers a cancellation result to Claude; ESC otherwise ends the query
- Export `IMessageStreamer` from the public barrel
- Failed API requests are logged with their cause, how they were classified, and each retry attempt and delay
- Inject a live per-turn date/time stamp into every request
- isSystemReminderBlock is now exported, so a consumer can tell a <system-reminder> block apart from a message's own words without reimplementing the test
- Mark a tool-schema field as a filesystem path and normalise all marked paths once from that marker, so the display, the permission check, and handler execution read one produced path
Expand Down
2 changes: 2 additions & 0 deletions packages/claude-sdk/changes.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,5 @@
{"description":"Stored credentials and the browser login are now separate services a consumer resolves and can substitute (ICredentialProvider and ILoginFlow), replacing AnthropicAuth. A per-request caller holds the credential provider, which cannot open a browser","category":"changed"}
{"description":"The OAuth callback's state is checked against the authorisation request it was built for, so a callback arriving from anywhere else is refused instead of exchanged","category":"security"}
{"description":"DurableConfig gains conversationReminders, for standing facts about the current conversation. They are injected and re-injected exactly as cachedReminders are, but sit after them, so the prefix cache marker still falls on the last cached block and a per-conversation value cannot cost the shared prefix its reuse","category":"added"}
{"description":"Failed API requests are logged with their cause, how they were classified, and each retry attempt and delay","category":"added"}
{"description":"AnthropicClient accepts an undici Dispatcher, so a consumer chooses the HTTP protocol its requests negotiate","category":"added"}
3 changes: 2 additions & 1 deletion packages/claude-sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -99,11 +99,12 @@
"@shellicar/build-version": "^2.0.0",
"@shellicar/typescript-config": "workspace:^",
"@tsconfig/node24": "^24.0.4",
"@types/node": "^25.9.5",
"@types/node": "^26.2.0",
"esbuild": "^0.28.0",
"tsup": "^8.5.1",
"tsx": "^4.22.5",
"typescript": "^5.9.3",
"undici": "^8.10.0",
"vitest": "^4.1.10"
}
}
9 changes: 8 additions & 1 deletion packages/claude-sdk/src/private/AnthropicClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { Anthropic } from '@anthropic-ai/sdk';
import type { BetaMessageStreamParams } from '@anthropic-ai/sdk/resources/beta/messages.js';
import versionJson from '@shellicar/build-version/version';
import type { ILogger } from '@shellicar/claude-core/logging/ILogger';
import type { Dispatcher } from 'undici';
import type { ICredentialProvider } from './Client/Auth/interfaces';
import { customFetch } from './http/customFetch';
import { streamMessages } from './http/transport';
Expand All @@ -22,16 +23,20 @@ import { type IMessageStream, IMessageStreamer } from './MessageStreamer';
export class AnthropicClient extends IMessageStreamer {
readonly #credentials: ICredentialProvider;
readonly #fetch: typeof fetch;
readonly #logger: ILogger;
readonly #dispatcher: Dispatcher | undefined;
readonly #defaultHeaders: Record<string, string> = {
'user-agent': `@shellicar/claude-sdk/${versionJson.version}`,
};

// The fetch wrapper is built once, eagerly, so a setup failure surfaces at
// composition (buildProvider) rather than on the first request. The app's
// composition root supplies the auth and logger through the factory.
public constructor(credentials: ICredentialProvider, logger: ILogger) {
public constructor(credentials: ICredentialProvider, logger: ILogger, dispatcher?: Dispatcher) {
super();
this.#credentials = credentials;
this.#logger = logger;
this.#dispatcher = dispatcher;
this.#fetch = customFetch(logger) as typeof fetch;
}

Expand All @@ -45,6 +50,8 @@ export class AnthropicClient extends IMessageStreamer {
authToken: this.#authToken,
fetch: this.#fetch,
defaultHeaders: this.#defaultHeaders,
logger: this.#logger,
dispatcher: this.#dispatcher,
});
}
}
19 changes: 13 additions & 6 deletions packages/claude-sdk/src/private/TurnRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,18 +186,24 @@ export class TurnRunner extends ITurnRunner {
this.requestClock.requestSettled(false);
// ESC during the request: a normal in-flight cancel, never retried.
if (turnInput.abortSignal.aborted) {
this.logger.debug('request cancelled in flight');
throw err;
}

this.logger.warn('request failed', { error: err, retryable: isRetryable(err), transientAttempt, streamInterruptAttempt });

// Account-limit 429 (retry-after exceeds the 60s cap): non-transient.
// The give-up decision is made immediately after each 429, before any wait.
if (isAccountLimit(err, RETRY_AFTER_CAP_MS)) {
const now = this.clock.instant();
firstAccountLimitAt ??= now;
if (Duration.between(firstAccountLimitAt, now).toMillis() >= ACCOUNT_LIMIT_BUDGET_MS) {
const waitedMs = Duration.between(firstAccountLimitAt, now).toMillis();
if (waitedMs >= ACCOUNT_LIMIT_BUDGET_MS) {
this.logger.error('account limit; budget exhausted, giving up', { waitedMs, budgetMs: ACCOUNT_LIMIT_BUDGET_MS });
this.accountLimit.stopped();
throw new AccountLimitStoppedError();
}
this.logger.warn('account limit; waiting', { waitedMs, budgetMs: ACCOUNT_LIMIT_BUDGET_MS, delayMs: RETRY_AFTER_CAP_MS });
this.accountLimit.retrying();
await this.sleeper.sleep(RETRY_AFTER_CAP_MS, turnInput.abortSignal);
if (turnInput.abortSignal.aborted) {
Expand All @@ -214,9 +220,10 @@ export class TurnRunner extends ITurnRunner {
if (err instanceof StreamInterruptedError) {
streamInterruptAttempt++;
if (streamInterruptAttempt > STREAM_INTERRUPT_MAX_RETRIES) {
this.logger.error('stream interrupted; retries exhausted', { attempt: streamInterruptAttempt, maxRetries: STREAM_INTERRUPT_MAX_RETRIES });
throw err;
}
this.logger.warn('stream interrupted; reconnecting', { attempt: streamInterruptAttempt });
this.logger.warn('stream interrupted; reconnecting', { attempt: streamInterruptAttempt, maxRetries: STREAM_INTERRUPT_MAX_RETRIES, delayMs: STREAM_INTERRUPT_DELAY_MS });
this.interruption.reconnecting();
await this.sleeper.sleep(STREAM_INTERRUPT_DELAY_MS, turnInput.abortSignal);
if (turnInput.abortSignal.aborted) {
Expand All @@ -228,12 +235,12 @@ export class TurnRunner extends ITurnRunner {
// Other transient errors: existing exponential backoff + jitter, bounded.
transientAttempt++;
if (!isRetryable(err) || transientAttempt > MAX_RETRIES) {
this.logger.error('giving up', { reason: isRetryable(err) ? 'retries exhausted' : 'not retryable', attempt: transientAttempt, maxRetries: MAX_RETRIES });
throw err;
}
await this.sleeper.sleep(
calculateBackoffDelay(transientAttempt, () => this.random.next()),
turnInput.abortSignal,
);
const delayMs = calculateBackoffDelay(transientAttempt, () => this.random.next());
this.logger.warn('retrying after backoff', { attempt: transientAttempt, maxRetries: MAX_RETRIES, delayMs });
await this.sleeper.sleep(delayMs, turnInput.abortSignal);
if (turnInput.abortSignal.aborted) {
// On abort, surface a standard cancel: throwIfAborted() throws signal.reason
// (a DOMException when abort() has no reason). Deliberately not the SDK's
Expand Down
12 changes: 11 additions & 1 deletion packages/claude-sdk/src/private/http/customFetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,15 @@ export const customFetch = (logger: ILogger | undefined) => {
method: init?.method,
body,
});
const response = await fetch(input, init);
const startMs = Date.now();
let response: Response;
// try {
response = await fetch(input, init);
// } catch (error) {
// logger?.error('HTTP Request failed', { method: init?.method, elapsedMs: Date.now() - startMs, error });
// throw error;
// }
const elapsedMs = Date.now() - startMs;
const isStream = response.headers.get('content-type')?.includes('text/event-stream') ?? false;
if (!isStream) {
const text = await response.clone().text();
Expand All @@ -26,13 +34,15 @@ export const customFetch = (logger: ILogger | undefined) => {
headers: getHeaders(response.headers),
status: response.status,
statusText: response.statusText,
elapsedMs,
body: responseBody,
});
} else {
logger?.info('HTTP Response', {
headers: getHeaders(response.headers),
status: response.status,
statusText: response.statusText,
elapsedMs,
});
}
return response;
Expand Down
Loading
Loading