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
43 changes: 43 additions & 0 deletions apps/server/probe-claude.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { query } from "@anthropic-ai/claude-agent-sdk";

function waitForAbortSignal(signal) {
if (signal.aborted) return Promise.resolve();
return new Promise((resolve) => {
signal.addEventListener("abort", () => resolve(), { once: true });
});
}

const abort = new AbortController();

const q = query({
prompt: (async function* () {
await waitForAbortSignal(abort.signal);
})(),
options: {
persistSession: false,
pathToClaudeCodeExecutable: "/Users/tyulyukov/.local/bin/claude",
abortController: abort,
settingSources: ["user", "project", "local"],
allowedTools: [],
stderr: (line) => {
console.error("[claude stderr]", line);
},
},
});

const timeoutId = setTimeout(() => {
console.error("TIMEOUT after 10s");
abort.abort();
}, 10000);

try {
const init = await q.initializationResult();
console.log("subscriptionType:", init.account?.subscriptionType);
console.log("commands count:", init.commands?.length);
console.log("commands:", JSON.stringify(init.commands?.slice(0, 20), null, 2));
} catch (err) {
console.error("ERROR:", err);
} finally {
clearTimeout(timeoutId);
if (!abort.signal.aborted) abort.abort();
}
17 changes: 10 additions & 7 deletions apps/server/src/provider/Layers/ClaudeAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2939,6 +2939,13 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
...(fastMode ? { fastMode: true } : {}),
};

const computedAdditionalDirs = (() => {
const dirs: string[] = [];
if (input.cwd) dirs.push(input.cwd);
if (input.additionalDirectories) dirs.push(...input.additionalDirectories);
return [...new Set(dirs)];
})();

const queryOptions: ClaudeQueryOptions = {
...(input.cwd ? { cwd: input.cwd } : {}),
...(apiModelId ? { model: apiModelId } : {}),
Expand All @@ -2955,13 +2962,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
includePartialMessages: true,
canUseTool,
env: process.env,
...(() => {
const dirs: string[] = [];
if (input.cwd) dirs.push(input.cwd);
if (input.additionalDirectories) dirs.push(...input.additionalDirectories);
const uniqueDirs = [...new Set(dirs)];
return uniqueDirs.length > 0 ? { additionalDirectories: uniqueDirs } : {};
})(),
...(computedAdditionalDirs.length > 0
? { additionalDirectories: computedAdditionalDirs }
: {}),
};

const queryRuntime = yield* Effect.try({
Expand Down
26 changes: 21 additions & 5 deletions apps/server/src/provider/Layers/ClaudeProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { decodeJsonResult } from "@marcode/shared/schemaJson";
import {
query as claudeQuery,
type SlashCommand as ClaudeSlashCommand,
type SDKUserMessage,
} from "@anthropic-ai/claude-agent-sdk";

import {
Expand Down Expand Up @@ -454,13 +455,24 @@ function dedupeSlashCommands(
return [...commandsByName.values()];
}

function waitForAbortSignal(signal: AbortSignal): Promise<void> {
if (signal.aborted) {
return Promise.resolve();
}
return new Promise((resolve) => {
signal.addEventListener("abort", () => resolve(), { once: true });
});
}

/**
* Probe account information by spawning a lightweight Claude Agent SDK
* session and reading the initialization result.
*
* The prompt is never sent to the Anthropic API — we abort immediately
* after the local initialization phase completes. This gives us the
* user's subscription type without incurring any token cost.
* We pass a never-yielding AsyncIterable as the prompt so that no user
* message is ever written to the subprocess stdin. This means the Claude
* Code subprocess completes its local initialization IPC (returning
* account info and slash commands) but never starts an API request to
* Anthropic. We read the init data and then abort the subprocess.
*
* This is used as a fallback when `claude auth status` does not include
* subscription type information.
Expand All @@ -469,12 +481,16 @@ const probeClaudeCapabilities = (binaryPath: string) => {
const abort = new AbortController();
return Effect.tryPromise(async () => {
const q = claudeQuery({
prompt: ".",
// Never yield — we only need initialization data, not a conversation.
// This prevents any prompt from reaching the Anthropic API.
// oxlint-disable-next-line require-yield
prompt: (async function* (): AsyncGenerator<SDKUserMessage> {
await waitForAbortSignal(abort.signal);
})(),
options: {
persistSession: false,
pathToClaudeCodeExecutable: binaryPath,
abortController: abort,
maxTurns: 0,
settingSources: ["user", "project", "local"],
allowedTools: [],
stderr: () => {},
Expand Down
15 changes: 14 additions & 1 deletion apps/server/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,18 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>
createdAt: bootstrap.createThread.createdAt,
});
createdThread = true;

if (
bootstrap.createThread.additionalDirectories &&
bootstrap.createThread.additionalDirectories.length > 0
) {
yield* orchestrationEngine.dispatch({
type: "thread.meta.update",
commandId: serverCommandId("bootstrap-thread-additional-directories"),
threadId: command.threadId,
additionalDirectories: bootstrap.createThread.additionalDirectories,
});
}
}

if (bootstrap?.prepareWorktree) {
Expand Down Expand Up @@ -927,11 +939,12 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>
})),
);

const initialConfig = yield* loadServerConfig;
return Stream.concat(
Stream.make({
version: 1 as const,
type: "snapshot" as const,
config: yield* loadServerConfig,
config: initialConfig,
}),
Stream.merge(keybindingsUpdates, Stream.merge(providerStatuses, settingsUpdates)),
);
Expand Down
3 changes: 0 additions & 3 deletions apps/web/src/components/ChatView.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -483,7 +483,6 @@ describe("hasServerAcknowledgedLocalDispatch", () => {
localDispatch,
phase: "ready",
latestTurn: previousLatestTurn,
session: previousSession,
hasPendingApproval: false,
hasPendingUserInput: false,
threadError: null,
Expand Down Expand Up @@ -528,7 +527,6 @@ describe("hasServerAcknowledgedLocalDispatch", () => {
startedAt: "2026-03-29T00:01:01.000Z",
completedAt: "2026-03-29T00:01:30.000Z",
},
session: null,
hasPendingApproval: false,
hasPendingUserInput: false,
threadError: null,
Expand Down Expand Up @@ -567,7 +565,6 @@ describe("hasServerAcknowledgedLocalDispatch", () => {
localDispatch,
phase: "ready",
latestTurn: previousLatestTurn,
session: previousSession,
hasPendingApproval: false,
hasPendingUserInput: false,
threadError: null,
Expand Down
12 changes: 1 addition & 11 deletions apps/web/src/components/ChatView.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import {
type ChatMessage,
type SessionPhase,
type Thread,
type ThreadSession,
} from "../types";
import { randomUUID } from "~/lib/utils";
import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore";
Expand Down Expand Up @@ -313,33 +312,27 @@ export interface LocalDispatchSnapshot {
latestTurnRequestedAt: string | null;
latestTurnStartedAt: string | null;
latestTurnCompletedAt: string | null;
sessionOrchestrationStatus: ThreadSession["orchestrationStatus"] | null;
sessionUpdatedAt: string | null;
}

export function createLocalDispatchSnapshot(
activeThread: Thread | undefined,
options?: { preparingWorktree?: boolean },
): LocalDispatchSnapshot {
const latestTurn = activeThread?.latestTurn ?? null;
const session = activeThread?.session ?? null;
return {
startedAt: new Date().toISOString(),
preparingWorktree: Boolean(options?.preparingWorktree),
latestTurnTurnId: latestTurn?.turnId ?? null,
latestTurnRequestedAt: latestTurn?.requestedAt ?? null,
latestTurnStartedAt: latestTurn?.startedAt ?? null,
latestTurnCompletedAt: latestTurn?.completedAt ?? null,
sessionOrchestrationStatus: session?.orchestrationStatus ?? null,
sessionUpdatedAt: session?.updatedAt ?? null,
};
}

export function hasServerAcknowledgedLocalDispatch(input: {
localDispatch: LocalDispatchSnapshot | null;
phase: SessionPhase;
latestTurn: Thread["latestTurn"] | null;
session: Thread["session"] | null;
hasPendingApproval: boolean;
hasPendingUserInput: boolean;
threadError: string | null | undefined;
Expand All @@ -357,15 +350,12 @@ export function hasServerAcknowledgedLocalDispatch(input: {
}

const latestTurn = input.latestTurn ?? null;
const session = input.session ?? null;

return (
input.localDispatch.latestTurnTurnId !== (latestTurn?.turnId ?? null) ||
input.localDispatch.latestTurnRequestedAt !== (latestTurn?.requestedAt ?? null) ||
input.localDispatch.latestTurnStartedAt !== (latestTurn?.startedAt ?? null) ||
input.localDispatch.latestTurnCompletedAt !== (latestTurn?.completedAt ?? null) ||
input.localDispatch.sessionOrchestrationStatus !== (session?.orchestrationStatus ?? null) ||
input.localDispatch.sessionUpdatedAt !== (session?.updatedAt ?? null)
input.localDispatch.latestTurnCompletedAt !== (latestTurn?.completedAt ?? null)
);
}

Expand Down
Loading
Loading