diff --git a/apps/server/probe-claude.mjs b/apps/server/probe-claude.mjs new file mode 100644 index 00000000000..b867bf1274b --- /dev/null +++ b/apps/server/probe-claude.mjs @@ -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(); +} diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 26a797fb299..b81a1df689e 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -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 } : {}), @@ -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({ diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index a08ac1fb88c..3a2c4f97638 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -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 { @@ -454,13 +455,24 @@ function dedupeSlashCommands( return [...commandsByName.values()]; } +function waitForAbortSignal(signal: AbortSignal): Promise { + 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. @@ -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 { + await waitForAbortSignal(abort.signal); + })(), options: { persistSession: false, pathToClaudeCodeExecutable: binaryPath, abortController: abort, - maxTurns: 0, settingSources: ["user", "project", "local"], allowedTools: [], stderr: () => {}, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index fe03095e9df..8681c15c56d 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -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) { @@ -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)), ); diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 70d8e55b123..f5c4ad3b4c1 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -483,7 +483,6 @@ describe("hasServerAcknowledgedLocalDispatch", () => { localDispatch, phase: "ready", latestTurn: previousLatestTurn, - session: previousSession, hasPendingApproval: false, hasPendingUserInput: false, threadError: null, @@ -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, @@ -567,7 +565,6 @@ describe("hasServerAcknowledgedLocalDispatch", () => { localDispatch, phase: "ready", latestTurn: previousLatestTurn, - session: previousSession, hasPendingApproval: false, hasPendingUserInput: false, threadError: null, diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 512811724d6..6e5f2859f96 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -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"; @@ -313,8 +312,6 @@ export interface LocalDispatchSnapshot { latestTurnRequestedAt: string | null; latestTurnStartedAt: string | null; latestTurnCompletedAt: string | null; - sessionOrchestrationStatus: ThreadSession["orchestrationStatus"] | null; - sessionUpdatedAt: string | null; } export function createLocalDispatchSnapshot( @@ -322,7 +319,6 @@ export function createLocalDispatchSnapshot( options?: { preparingWorktree?: boolean }, ): LocalDispatchSnapshot { const latestTurn = activeThread?.latestTurn ?? null; - const session = activeThread?.session ?? null; return { startedAt: new Date().toISOString(), preparingWorktree: Boolean(options?.preparingWorktree), @@ -330,8 +326,6 @@ export function createLocalDispatchSnapshot( latestTurnRequestedAt: latestTurn?.requestedAt ?? null, latestTurnStartedAt: latestTurn?.startedAt ?? null, latestTurnCompletedAt: latestTurn?.completedAt ?? null, - sessionOrchestrationStatus: session?.orchestrationStatus ?? null, - sessionUpdatedAt: session?.updatedAt ?? null, }; } @@ -339,7 +333,6 @@ 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; @@ -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) ); } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f01e45511f2..75850affbdd 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -228,6 +228,9 @@ import { ContextWindowMeter } from "./chat/ContextWindowMeter"; import { buildExpandedImagePreview, ExpandedImagePreview } from "./chat/ExpandedImagePreview"; import { AVAILABLE_PROVIDER_OPTIONS, ProviderModelPicker } from "./chat/ProviderModelPicker"; import { ComposerCommandItem, ComposerCommandMenu } from "./chat/ComposerCommandMenu"; +import { searchSlashCommandItems } from "./chat/composerSlashCommandSearch"; +import { formatProviderSkillDisplayName } from "../providerSkillPresentation"; +import { searchProviderSkills } from "../providerSkillSearch"; import { ComposerPendingApprovalActions } from "./chat/ComposerPendingApprovalActions"; import { CompactComposerControlsMenu } from "./chat/CompactComposerControlsMenu"; import { ComposerPrimaryActions } from "./chat/ComposerPrimaryActions"; @@ -507,7 +510,6 @@ function useLocalDispatchState(input: { localDispatch, phase: input.phase, latestTurn: input.activeLatestTurn, - session: input.activeThread?.session ?? null, hasPendingApproval: input.activePendingApproval !== null, hasPendingUserInput: input.activePendingUserInput !== null, threadError: input.threadError, @@ -1779,38 +1781,61 @@ export default function ChatView({ threadId, environmentId: environmentIdProp }: return [...jiraItems, ...fileItems]; } if (composerTrigger.kind === "slash-command") { - const allItems: ComposerCommandItem[] = [ + const builtInSlashCommandItems = [ { id: "slash:model", - type: "slash-command", - command: "model", + type: "slash-command" as const, + command: "model" as const, label: "/model", description: "Switch response model for this thread", }, { id: "slash:plan", - type: "slash-command", - command: "plan", + type: "slash-command" as const, + command: "plan" as const, label: "/plan", description: "Switch this thread into plan mode", }, { id: "slash:default", - type: "slash-command", - command: "default", + type: "slash-command" as const, + command: "default" as const, label: "/default", description: "Switch this thread back to normal build mode", }, ]; - const query = composerTrigger.query.trim().toLowerCase(); + const selectedProviderSlashCommands = + providerStatuses.find((provider) => provider.provider === selectedProvider) + ?.slashCommands ?? []; + const providerSlashCommandItems = selectedProviderSlashCommands.map((command) => ({ + id: `provider-slash-command:${selectedProvider}:${command.name}`, + type: "provider-slash-command" as const, + provider: selectedProvider, + command, + label: `/${command.name}`, + description: command.description ?? command.input?.hint ?? "Run provider command", + })); + const slashCommandItems = [...builtInSlashCommandItems, ...providerSlashCommandItems]; + const query = composerTrigger.query.trim(); if (!query) { - return allItems; + return slashCommandItems; } - return allItems.filter( - (item) => - item.label.toLowerCase().includes(query) || - item.description.toLowerCase().includes(query), - ); + return searchSlashCommandItems(slashCommandItems, query); + } + if (composerTrigger.kind === "skill") { + const selectedProviderSkills = + providerStatuses.find((provider) => provider.provider === selectedProvider)?.skills ?? []; + return searchProviderSkills(selectedProviderSkills, composerTrigger.query).map((skill) => ({ + id: `skill:${selectedProvider}:${skill.name}`, + type: "skill" as const, + provider: selectedProvider, + skill, + label: formatProviderSkillDisplayName(skill), + description: + skill.shortDescription ?? + skill.description ?? + (skill.scope ? `${skill.scope} skill` : "Run provider skill"), + })); } return searchableModelOptions @@ -1829,7 +1854,14 @@ export default function ChatView({ threadId, environmentId: environmentIdProp }: label: name, description: `${providerLabel} · ${slug}`, })); - }, [composerTrigger, jiraIssues, searchableModelOptions, workspaceEntries]); + }, [ + composerTrigger, + jiraIssues, + providerStatuses, + searchableModelOptions, + selectedProvider, + workspaceEntries, + ]); const composerMenuOpen = Boolean(composerTrigger); const activeComposerMenuItem = useMemo( () => @@ -3479,16 +3511,6 @@ export default function ChatView({ threadId, environmentId: environmentIdProp }: ...(selectedModelSelection.options ? { options: selectedModelSelection.options } : {}), }; - if (isLocalDraftThread && draftAdditionalDirectories.length > 0) { - await api.orchestration.dispatchCommand({ - type: "thread.meta.update", - commandId: newCommandId(), - threadId: threadIdForSend, - additionalDirectories: draftAdditionalDirectories, - }); - setDraftAdditionalDirectories([]); - } - if (isFirstMessage && isServerThread) { await api.orchestration.dispatchCommand({ type: "thread.meta.update", @@ -3522,6 +3544,9 @@ export default function ChatView({ threadId, environmentId: environmentIdProp }: interactionMode, branch: activeThread.branch, worktreePath: activeThread.worktreePath, + ...(draftAdditionalDirectories.length > 0 + ? { additionalDirectories: draftAdditionalDirectories } + : {}), createdAt: activeThread.createdAt, }, } @@ -3557,6 +3582,9 @@ export default function ChatView({ threadId, environmentId: environmentIdProp }: createdAt: messageCreatedAt, }); turnStartSucceeded = true; + if (isLocalDraftThread && draftAdditionalDirectories.length > 0) { + setDraftAdditionalDirectories([]); + } })().catch(async (err: unknown) => { if ( input.clearComposerDraft && @@ -4366,7 +4394,42 @@ export default function ChatView({ threadId, environmentId: environmentIdProp }: } return; } - if (item.type === "provider-slash-command" || item.type === "skill") return; + if (item.type === "provider-slash-command") { + const replacement = `/${item.command.name} `; + const replacementRangeEnd = extendReplacementRangeForTrailingSpace( + snapshot.value, + trigger.rangeEnd, + replacement, + ); + const applied = applyPromptReplacement( + trigger.rangeStart, + replacementRangeEnd, + replacement, + { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) }, + ); + if (applied) { + setComposerHighlightedItemId(null); + } + return; + } + if (item.type === "skill") { + const replacement = `$${item.skill.name} `; + const replacementRangeEnd = extendReplacementRangeForTrailingSpace( + snapshot.value, + trigger.rangeEnd, + replacement, + ); + const applied = applyPromptReplacement( + trigger.rangeStart, + replacementRangeEnd, + replacement, + { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) }, + ); + if (applied) { + setComposerHighlightedItemId(null); + } + return; + } onProviderModelSelect(item.provider, item.model); const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), diff --git a/apps/web/src/hooks/useSmoothReveal.ts b/apps/web/src/hooks/useSmoothReveal.ts index 26d8a7d7112..d5304cc6807 100644 --- a/apps/web/src/hooks/useSmoothReveal.ts +++ b/apps/web/src/hooks/useSmoothReveal.ts @@ -201,25 +201,30 @@ export function useSmoothReveal( let last = burstCount - 1; const tick = (now: number) => { - const idx = searchTimeline(tl, now - start); - - if (!spans[last + 1]?.isConnected) { + if (!el.isConnected) { rafRef.current = 0; spansRef.current = []; setIsRevealing(false); return; } + const idx = searchTimeline(tl, now - start); + while (last < idx - 1 && last < spans.length - 1) { last++; - spans[last]!.classList.add("tr-visible"); - revealDecorationForSpan(spans[last]!); + const span = spans[last]!; + if (!span.isConnected) continue; + span.classList.add("tr-visible"); + revealDecorationForSpan(span); } if (idx < spans.length) { rafRef.current = requestAnimationFrame(tick); } else { - for (let i = last + 1; i < spans.length; i++) spans[i]!.classList.add("tr-visible"); + for (let i = last + 1; i < spans.length; i++) { + const span = spans[i]!; + if (span.isConnected) span.classList.add("tr-visible"); + } setTimeout(() => { if (el.isConnected) { unwrapSpans(el); diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index cb745468de0..b3c9b066edb 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -452,6 +452,7 @@ const ThreadTurnStartBootstrapCreateThread = Schema.Struct({ interactionMode: ProviderInteractionMode, branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), + additionalDirectories: Schema.optional(Schema.Array(TrimmedNonEmptyString)), createdAt: IsoDateTime, });