Skip to content

Commit 2507778

Browse files
committed
feat: add provider slash commands and skills to composer, move additiona
- Add search and rendering for provider-specific slash commands and skills in composer menu - Move additionalDirectories from post-create thread.meta.update to ThreadTurnStartBootstrapCreateThread - Refactor probeClaudeCapabilities to use async generator prompt (no API calls) - Remove session orchestration tracking from LocalDispatchSnapshot (no longer needed) - Add isConnected guards in useSmoothReveal to handle DOM detachment during animation - Add debug logging for provider config updates - Improve additional directories deduplication in ClaudeAdapter
1 parent 7e1430b commit 2507778

10 files changed

Lines changed: 210 additions & 60 deletions

File tree

apps/server/probe-claude.mjs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { query } from "@anthropic-ai/claude-agent-sdk";
2+
3+
function waitForAbortSignal(signal) {
4+
if (signal.aborted) return Promise.resolve();
5+
return new Promise((resolve) => {
6+
signal.addEventListener("abort", () => resolve(), { once: true });
7+
});
8+
}
9+
10+
const abort = new AbortController();
11+
12+
const q = query({
13+
prompt: (async function* () {
14+
await waitForAbortSignal(abort.signal);
15+
})(),
16+
options: {
17+
persistSession: false,
18+
pathToClaudeCodeExecutable: "/Users/tyulyukov/.local/bin/claude",
19+
abortController: abort,
20+
settingSources: ["user", "project", "local"],
21+
allowedTools: [],
22+
stderr: (line) => {
23+
console.error("[claude stderr]", line);
24+
},
25+
},
26+
});
27+
28+
const timeoutId = setTimeout(() => {
29+
console.error("TIMEOUT after 10s");
30+
abort.abort();
31+
}, 10000);
32+
33+
try {
34+
const init = await q.initializationResult();
35+
console.log("subscriptionType:", init.account?.subscriptionType);
36+
console.log("commands count:", init.commands?.length);
37+
console.log("commands:", JSON.stringify(init.commands?.slice(0, 20), null, 2));
38+
} catch (err) {
39+
console.error("ERROR:", err);
40+
} finally {
41+
clearTimeout(timeoutId);
42+
if (!abort.signal.aborted) abort.abort();
43+
}

apps/server/src/provider/Layers/ClaudeAdapter.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2939,6 +2939,13 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
29392939
...(fastMode ? { fastMode: true } : {}),
29402940
};
29412941

2942+
const computedAdditionalDirs = (() => {
2943+
const dirs: string[] = [];
2944+
if (input.cwd) dirs.push(input.cwd);
2945+
if (input.additionalDirectories) dirs.push(...input.additionalDirectories);
2946+
return [...new Set(dirs)];
2947+
})();
2948+
29422949
const queryOptions: ClaudeQueryOptions = {
29432950
...(input.cwd ? { cwd: input.cwd } : {}),
29442951
...(apiModelId ? { model: apiModelId } : {}),
@@ -2955,13 +2962,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
29552962
includePartialMessages: true,
29562963
canUseTool,
29572964
env: process.env,
2958-
...(() => {
2959-
const dirs: string[] = [];
2960-
if (input.cwd) dirs.push(input.cwd);
2961-
if (input.additionalDirectories) dirs.push(...input.additionalDirectories);
2962-
const uniqueDirs = [...new Set(dirs)];
2963-
return uniqueDirs.length > 0 ? { additionalDirectories: uniqueDirs } : {};
2964-
})(),
2965+
...(computedAdditionalDirs.length > 0
2966+
? { additionalDirectories: computedAdditionalDirs }
2967+
: {}),
29652968
};
29662969

29672970
const queryRuntime = yield* Effect.try({

apps/server/src/provider/Layers/ClaudeProvider.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { decodeJsonResult } from "@marcode/shared/schemaJson";
1313
import {
1414
query as claudeQuery,
1515
type SlashCommand as ClaudeSlashCommand,
16+
type SDKUserMessage,
1617
} from "@anthropic-ai/claude-agent-sdk";
1718

1819
import {
@@ -454,13 +455,24 @@ function dedupeSlashCommands(
454455
return [...commandsByName.values()];
455456
}
456457

458+
function waitForAbortSignal(signal: AbortSignal): Promise<void> {
459+
if (signal.aborted) {
460+
return Promise.resolve();
461+
}
462+
return new Promise((resolve) => {
463+
signal.addEventListener("abort", () => resolve(), { once: true });
464+
});
465+
}
466+
457467
/**
458468
* Probe account information by spawning a lightweight Claude Agent SDK
459469
* session and reading the initialization result.
460470
*
461-
* The prompt is never sent to the Anthropic API — we abort immediately
462-
* after the local initialization phase completes. This gives us the
463-
* user's subscription type without incurring any token cost.
471+
* We pass a never-yielding AsyncIterable as the prompt so that no user
472+
* message is ever written to the subprocess stdin. This means the Claude
473+
* Code subprocess completes its local initialization IPC (returning
474+
* account info and slash commands) but never starts an API request to
475+
* Anthropic. We read the init data and then abort the subprocess.
464476
*
465477
* This is used as a fallback when `claude auth status` does not include
466478
* subscription type information.
@@ -469,12 +481,16 @@ const probeClaudeCapabilities = (binaryPath: string) => {
469481
const abort = new AbortController();
470482
return Effect.tryPromise(async () => {
471483
const q = claudeQuery({
472-
prompt: ".",
484+
// Never yield — we only need initialization data, not a conversation.
485+
// This prevents any prompt from reaching the Anthropic API.
486+
// oxlint-disable-next-line require-yield
487+
prompt: (async function* (): AsyncGenerator<SDKUserMessage> {
488+
await waitForAbortSignal(abort.signal);
489+
})(),
473490
options: {
474491
persistSession: false,
475492
pathToClaudeCodeExecutable: binaryPath,
476493
abortController: abort,
477-
maxTurns: 0,
478494
settingSources: ["user", "project", "local"],
479495
allowedTools: [],
480496
stderr: () => {},

apps/server/src/ws.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,18 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>
389389
createdAt: bootstrap.createThread.createdAt,
390390
});
391391
createdThread = true;
392+
393+
if (
394+
bootstrap.createThread.additionalDirectories &&
395+
bootstrap.createThread.additionalDirectories.length > 0
396+
) {
397+
yield* orchestrationEngine.dispatch({
398+
type: "thread.meta.update",
399+
commandId: serverCommandId("bootstrap-thread-additional-directories"),
400+
threadId: command.threadId,
401+
additionalDirectories: bootstrap.createThread.additionalDirectories,
402+
});
403+
}
392404
}
393405

394406
if (bootstrap?.prepareWorktree) {
@@ -927,11 +939,12 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>
927939
})),
928940
);
929941

942+
const initialConfig = yield* loadServerConfig;
930943
return Stream.concat(
931944
Stream.make({
932945
version: 1 as const,
933946
type: "snapshot" as const,
934-
config: yield* loadServerConfig,
947+
config: initialConfig,
935948
}),
936949
Stream.merge(keybindingsUpdates, Stream.merge(providerStatuses, settingsUpdates)),
937950
);

apps/web/src/components/ChatView.logic.test.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -483,7 +483,6 @@ describe("hasServerAcknowledgedLocalDispatch", () => {
483483
localDispatch,
484484
phase: "ready",
485485
latestTurn: previousLatestTurn,
486-
session: previousSession,
487486
hasPendingApproval: false,
488487
hasPendingUserInput: false,
489488
threadError: null,
@@ -528,7 +527,6 @@ describe("hasServerAcknowledgedLocalDispatch", () => {
528527
startedAt: "2026-03-29T00:01:01.000Z",
529528
completedAt: "2026-03-29T00:01:30.000Z",
530529
},
531-
session: null,
532530
hasPendingApproval: false,
533531
hasPendingUserInput: false,
534532
threadError: null,
@@ -567,7 +565,6 @@ describe("hasServerAcknowledgedLocalDispatch", () => {
567565
localDispatch,
568566
phase: "ready",
569567
latestTurn: previousLatestTurn,
570-
session: previousSession,
571568
hasPendingApproval: false,
572569
hasPendingUserInput: false,
573570
threadError: null,

apps/web/src/components/ChatView.logic.ts

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ import {
1313
type ChatMessage,
1414
type SessionPhase,
1515
type Thread,
16-
type ThreadSession,
1716
} from "../types";
1817
import { randomUUID } from "~/lib/utils";
1918
import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore";
@@ -313,33 +312,27 @@ export interface LocalDispatchSnapshot {
313312
latestTurnRequestedAt: string | null;
314313
latestTurnStartedAt: string | null;
315314
latestTurnCompletedAt: string | null;
316-
sessionOrchestrationStatus: ThreadSession["orchestrationStatus"] | null;
317-
sessionUpdatedAt: string | null;
318315
}
319316

320317
export function createLocalDispatchSnapshot(
321318
activeThread: Thread | undefined,
322319
options?: { preparingWorktree?: boolean },
323320
): LocalDispatchSnapshot {
324321
const latestTurn = activeThread?.latestTurn ?? null;
325-
const session = activeThread?.session ?? null;
326322
return {
327323
startedAt: new Date().toISOString(),
328324
preparingWorktree: Boolean(options?.preparingWorktree),
329325
latestTurnTurnId: latestTurn?.turnId ?? null,
330326
latestTurnRequestedAt: latestTurn?.requestedAt ?? null,
331327
latestTurnStartedAt: latestTurn?.startedAt ?? null,
332328
latestTurnCompletedAt: latestTurn?.completedAt ?? null,
333-
sessionOrchestrationStatus: session?.orchestrationStatus ?? null,
334-
sessionUpdatedAt: session?.updatedAt ?? null,
335329
};
336330
}
337331

338332
export function hasServerAcknowledgedLocalDispatch(input: {
339333
localDispatch: LocalDispatchSnapshot | null;
340334
phase: SessionPhase;
341335
latestTurn: Thread["latestTurn"] | null;
342-
session: Thread["session"] | null;
343336
hasPendingApproval: boolean;
344337
hasPendingUserInput: boolean;
345338
threadError: string | null | undefined;
@@ -357,15 +350,12 @@ export function hasServerAcknowledgedLocalDispatch(input: {
357350
}
358351

359352
const latestTurn = input.latestTurn ?? null;
360-
const session = input.session ?? null;
361353

362354
return (
363355
input.localDispatch.latestTurnTurnId !== (latestTurn?.turnId ?? null) ||
364356
input.localDispatch.latestTurnRequestedAt !== (latestTurn?.requestedAt ?? null) ||
365357
input.localDispatch.latestTurnStartedAt !== (latestTurn?.startedAt ?? null) ||
366-
input.localDispatch.latestTurnCompletedAt !== (latestTurn?.completedAt ?? null) ||
367-
input.localDispatch.sessionOrchestrationStatus !== (session?.orchestrationStatus ?? null) ||
368-
input.localDispatch.sessionUpdatedAt !== (session?.updatedAt ?? null)
358+
input.localDispatch.latestTurnCompletedAt !== (latestTurn?.completedAt ?? null)
369359
);
370360
}
371361

0 commit comments

Comments
 (0)