Skip to content

Commit 1b6a1dd

Browse files
committed
feat: MCP mid-session management, granular sandbox, additional dirs, fork session, hooks system
- MCP: toggle/reconnect servers mid-session via /mcp command - Sandbox: granular config (network, filesystem, ignoreViolations, excludedCommands) - Additional directories: multi-repo access via settings - Fork session: option to fork Claude sessions - Hooks: passive SDK callbacks for tool/notification/task observability - Hooks route events to Discord as system messages when enabled - Updated help docs with MCP and new feature entries
1 parent c81c720 commit 1b6a1dd

File tree

9 files changed

+496
-10
lines changed

9 files changed

+496
-10
lines changed

claude/client.ts

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { query as claudeQuery, type SDKMessage, type AgentDefinition as SDKAgentDefinition, type ModelInfo as SDKModelInfo, type SdkBeta, type McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
1+
import { query as claudeQuery, type SDKMessage, type AgentDefinition as SDKAgentDefinition, type ModelInfo as SDKModelInfo, type SdkBeta, type McpServerConfig, type HookEvent, type HookCallbackMatcher } from "@anthropic-ai/claude-agent-sdk";
22
import { setActiveQuery, trackMessageId, clearTrackedMessages } from "./query-manager.ts";
33
import type { AskUserQuestionInput, AskUserCallback } from "./user-question.ts";
44
import * as path from "https://deno.land/std@0.208.0/path/mod.ts";
@@ -111,10 +111,36 @@ export interface ClaudeModelOptions {
111111
betas?: SdkBeta[];
112112
/** Enable file checkpointing for undo/rewind */
113113
enableFileCheckpointing?: boolean;
114-
/** Sandbox settings for safer command execution */
115-
sandbox?: { enabled: boolean; autoAllowBashIfSandboxed?: boolean };
114+
/** Sandbox settings for safer command execution — full SDK SandboxSettings */
115+
sandbox?: {
116+
enabled?: boolean;
117+
autoAllowBashIfSandboxed?: boolean;
118+
allowUnsandboxedCommands?: boolean;
119+
network?: {
120+
allowedDomains?: string[];
121+
allowManagedDomainsOnly?: boolean;
122+
allowUnixSockets?: string[];
123+
allowAllUnixSockets?: boolean;
124+
allowLocalBinding?: boolean;
125+
httpProxyPort?: number;
126+
socksProxyPort?: number;
127+
};
128+
filesystem?: {
129+
allowWrite?: string[];
130+
denyWrite?: string[];
131+
denyRead?: string[];
132+
};
133+
ignoreViolations?: Record<string, string[]>;
134+
excludedCommands?: string[];
135+
};
116136
/** Enable experimental Agent Teams (multi-agent collaboration) */
117137
enableAgentTeams?: boolean;
138+
/** Additional directories Claude can access beyond cwd (absolute paths) */
139+
additionalDirectories?: string[];
140+
/** Fork a resumed session into a new session instead of continuing the original */
141+
forkSession?: boolean;
142+
/** SDK hooks — deep integration callbacks for tool use, notifications, etc. */
143+
hooks?: Partial<Record<HookEvent, HookCallbackMatcher[]>>;
118144
/** Structured output format (JSON schema) */
119145
outputFormat?: { type: 'json_schema'; schema: Record<string, unknown> };
120146
/** Callback for AskUserQuestion tool — Claude asks the user mid-session.
@@ -212,10 +238,14 @@ export async function sendToClaudeCode(
212238
// Native SDK agent support
213239
...(modelOptions?.agents && { agents: modelOptions.agents }),
214240
...(modelOptions?.agent && { agent: modelOptions.agent }),
215-
// Advanced features: betas, file checkpointing, sandbox
241+
// Advanced features: betas, file checkpointing, sandbox, additional dirs, fork
216242
...(modelOptions?.betas && modelOptions.betas.length > 0 && { betas: modelOptions.betas }),
217243
...(modelOptions?.enableFileCheckpointing && { enableFileCheckpointing: true }),
218244
...(modelOptions?.sandbox && { sandbox: modelOptions.sandbox }),
245+
...(modelOptions?.additionalDirectories && modelOptions.additionalDirectories.length > 0 && { additionalDirectories: modelOptions.additionalDirectories }),
246+
...(modelOptions?.forkSession && { forkSession: true }),
247+
// SDK hooks — deep integration callbacks
248+
...(modelOptions?.hooks && Object.keys(modelOptions.hooks).length > 0 && { hooks: modelOptions.hooks }),
219249
...(modelOptions?.outputFormat && { outputFormat: modelOptions.outputFormat }),
220250
// MCP servers from .claude/mcp.json
221251
...(mcpServers && { mcpServers }),

claude/hooks.ts

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
/**
2+
* SDK Hooks — Discord-integrated hook callbacks for Claude Code SDK.
3+
*
4+
* Hooks provide deep integration points that fire during query execution:
5+
* - PreToolUse: Log/audit tool usage before execution
6+
* - PostToolUse: Log completed tool usage
7+
* - PostToolUseFailure: Log tool failures
8+
* - Notification: Forward Claude's notifications to Discord
9+
* - TaskCompleted: Notify when background tasks finish
10+
*
11+
* All hooks are passive observers — they log to Discord but don't block execution.
12+
* They return `{ continue: true }` to let the SDK proceed normally.
13+
*
14+
* @module claude/hooks
15+
*/
16+
17+
import type {
18+
HookCallbackMatcher,
19+
HookCallback,
20+
HookInput,
21+
PreToolUseHookInput,
22+
PostToolUseHookInput,
23+
PostToolUseFailureHookInput,
24+
NotificationHookInput,
25+
TaskCompletedHookInput,
26+
HookEvent,
27+
SyncHookJSONOutput,
28+
} from "@anthropic-ai/claude-agent-sdk";
29+
30+
/**
31+
* Configuration for which hooks to enable and how to route events.
32+
*/
33+
export interface HookConfig {
34+
/** Enable tool-use logging (PreToolUse + PostToolUse + PostToolUseFailure) */
35+
logToolUse: boolean;
36+
/** Enable notification forwarding */
37+
logNotifications: boolean;
38+
/** Enable task completion notifications */
39+
logTaskCompletions: boolean;
40+
/** Callback to send hook events to Discord */
41+
onHookEvent: (event: HookEvent_Discord) => void;
42+
}
43+
44+
/**
45+
* Discord-formatted hook event for display.
46+
*/
47+
export interface HookEvent_Discord {
48+
type: 'tool_start' | 'tool_complete' | 'tool_failure' | 'notification' | 'task_completed';
49+
toolName?: string;
50+
// deno-lint-ignore no-explicit-any
51+
toolInput?: any;
52+
// deno-lint-ignore no-explicit-any
53+
toolResponse?: any;
54+
error?: string;
55+
message?: string;
56+
title?: string;
57+
taskId?: string;
58+
taskSubject?: string;
59+
timestamp: number;
60+
}
61+
62+
/**
63+
* Build SDK hook callbacks based on the provided configuration.
64+
* Returns a Partial<Record<HookEvent, HookCallbackMatcher[]>> suitable
65+
* for passing directly to the SDK query `options.hooks`.
66+
*/
67+
export function buildHooks(config: HookConfig): Partial<Record<HookEvent, HookCallbackMatcher[]>> {
68+
const hooks: Partial<Record<HookEvent, HookCallbackMatcher[]>> = {};
69+
70+
if (config.logToolUse) {
71+
// PreToolUse — log when a tool is about to be used
72+
const preToolHook: HookCallback = async (input: HookInput) => {
73+
const preInput = input as PreToolUseHookInput;
74+
config.onHookEvent({
75+
type: 'tool_start',
76+
toolName: preInput.tool_name,
77+
toolInput: preInput.tool_input,
78+
timestamp: Date.now(),
79+
});
80+
// Passive: don't block, don't modify
81+
return { continue: true } satisfies SyncHookJSONOutput;
82+
};
83+
84+
hooks.PreToolUse = [{
85+
hooks: [preToolHook],
86+
}];
87+
88+
// PostToolUse — log after a tool runs successfully
89+
const postToolHook: HookCallback = async (input: HookInput) => {
90+
const postInput = input as PostToolUseHookInput;
91+
config.onHookEvent({
92+
type: 'tool_complete',
93+
toolName: postInput.tool_name,
94+
toolInput: postInput.tool_input,
95+
toolResponse: postInput.tool_response,
96+
timestamp: Date.now(),
97+
});
98+
return { continue: true } satisfies SyncHookJSONOutput;
99+
};
100+
101+
hooks.PostToolUse = [{
102+
hooks: [postToolHook],
103+
}];
104+
105+
// PostToolUseFailure — log tool failures
106+
const failureHook: HookCallback = async (input: HookInput) => {
107+
const failInput = input as PostToolUseFailureHookInput;
108+
config.onHookEvent({
109+
type: 'tool_failure',
110+
toolName: failInput.tool_name,
111+
toolInput: failInput.tool_input,
112+
error: failInput.error,
113+
timestamp: Date.now(),
114+
});
115+
return { continue: true } satisfies SyncHookJSONOutput;
116+
};
117+
118+
hooks.PostToolUseFailure = [{
119+
hooks: [failureHook],
120+
}];
121+
}
122+
123+
if (config.logNotifications) {
124+
// Notification — forward Claude's notifications to Discord
125+
const notificationHook: HookCallback = async (input: HookInput) => {
126+
const notifInput = input as NotificationHookInput;
127+
config.onHookEvent({
128+
type: 'notification',
129+
message: notifInput.message,
130+
title: notifInput.title,
131+
timestamp: Date.now(),
132+
});
133+
return { continue: true } satisfies SyncHookJSONOutput;
134+
};
135+
136+
hooks.Notification = [{
137+
hooks: [notificationHook],
138+
}];
139+
}
140+
141+
if (config.logTaskCompletions) {
142+
// TaskCompleted — notify when background tasks finish
143+
const taskHook: HookCallback = async (input: HookInput) => {
144+
const taskInput = input as TaskCompletedHookInput;
145+
config.onHookEvent({
146+
type: 'task_completed',
147+
taskId: taskInput.task_id,
148+
taskSubject: taskInput.task_subject,
149+
message: taskInput.task_description,
150+
timestamp: Date.now(),
151+
});
152+
return { continue: true } satisfies SyncHookJSONOutput;
153+
};
154+
155+
hooks.TaskCompleted = [{
156+
hooks: [taskHook],
157+
}];
158+
}
159+
160+
return hooks;
161+
}

claude/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,14 @@ export {
4646
getSupportedModels,
4747
getMcpServerStatus,
4848
fetchClaudeInfo,
49+
toggleMcpServerActive,
50+
reconnectMcpServerActive,
51+
setMcpServersActive,
4952
} from "./query-manager.ts";
5053
export type { ClaudeInitInfo, RewindFilesResult } from "./query-manager.ts";
54+
// Hooks — passive SDK callbacks for tool/notification/task observability
55+
export { buildHooks } from "./hooks.ts";
56+
export type { HookConfig, HookEvent_Discord } from "./hooks.ts";
5157
// AskUserQuestion — interactive question flow (SDK v0.1.71+)
5258
export { buildQuestionMessages, parseAskUserButtonId, parseAskUserConfirmId } from "./user-question.ts";
5359
export type { AskUserCallback, AskUserQuestionInput, AskUserQuestionItem, AskUserOption } from "./user-question.ts";

claude/query-manager.ts

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@
99
* @module claude/query-manager
1010
*/
1111

12-
import { query as claudeQuery, type Query, type AccountInfo, type ModelInfo, type McpServerStatus, type SlashCommand, type RewindFilesResult, type PermissionMode } from "@anthropic-ai/claude-agent-sdk";
12+
import { query as claudeQuery, type Query, type AccountInfo, type ModelInfo, type McpServerStatus, type SlashCommand, type RewindFilesResult, type PermissionMode, type McpSetServersResult, type McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
1313

1414
// Re-export SDK types for consumers
15-
export type { Query, AccountInfo, ModelInfo as SDKModelInfoFull, McpServerStatus, SlashCommand as SDKSlashCommand, RewindFilesResult };
15+
export type { Query, AccountInfo, ModelInfo as SDKModelInfoFull, McpServerStatus, SlashCommand as SDKSlashCommand, RewindFilesResult, McpSetServersResult, McpServerConfig };
1616

1717
/**
1818
* Full initialization result from the SDK.
@@ -219,6 +219,58 @@ export async function stopActiveTask(taskId: string): Promise<boolean> {
219219
}
220220
}
221221

222+
// ================================
223+
// MCP Server Management
224+
// ================================
225+
226+
/**
227+
* Toggle an MCP server on/off mid-session via the SDK Query.
228+
*
229+
* @param serverName - The name of the MCP server to toggle
230+
* @param enabled - Whether the server should be enabled
231+
*/
232+
export async function toggleMcpServerActive(serverName: string, enabled: boolean): Promise<boolean> {
233+
if (!activeQuery) return false;
234+
try {
235+
await activeQuery.toggleMcpServer(serverName, enabled);
236+
return true;
237+
} catch {
238+
return false;
239+
}
240+
}
241+
242+
/**
243+
* Reconnect an MCP server mid-session via the SDK Query.
244+
* Useful when a server has failed or disconnected.
245+
*
246+
* @param serverName - The name of the MCP server to reconnect
247+
*/
248+
export async function reconnectMcpServerActive(serverName: string): Promise<boolean> {
249+
if (!activeQuery) return false;
250+
try {
251+
await activeQuery.reconnectMcpServer(serverName);
252+
return true;
253+
} catch {
254+
return false;
255+
}
256+
}
257+
258+
/**
259+
* Dynamically set MCP servers mid-session via the SDK Query.
260+
* Replaces the current set of dynamically-added servers.
261+
* Servers from settings files are not affected.
262+
*
263+
* @param servers - Record of server name to configuration
264+
*/
265+
export async function setMcpServersActive(servers: Record<string, McpServerConfig>): Promise<McpSetServersResult | null> {
266+
if (!activeQuery) return null;
267+
try {
268+
return await activeQuery.setMcpServers(servers);
269+
} catch {
270+
return null;
271+
}
272+
}
273+
222274
// ================================
223275
// Ephemeral Info Query
224276
// ================================

core/command-wrappers.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -462,7 +462,8 @@ export function createSettingsCommandHandlers(
462462
const serverName = ctx.getString('server_name');
463463
const command = ctx.getString('command');
464464
const description = ctx.getString('description');
465-
await unifiedSettingsHandlers.onMCP(ctx, action, serverName || undefined, command || undefined, description || undefined);
465+
const value = ctx.getString('value');
466+
await unifiedSettingsHandlers.onMCP(ctx, action, serverName || undefined, command || undefined, description || undefined, value || undefined);
466467
}
467468
}],
468469
['agent', {

core/handler-registry.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ import { infoCommands, createInfoCommandHandlers } from "../claude/index.ts";
3030
import { cleanSessionId, ClaudeSessionManager } from "../claude/index.ts";
3131
import type { ClaudeModelOptions } from "../claude/index.ts";
3232
import type { AskUserCallback } from "../claude/index.ts";
33+
import { buildHooks } from "../claude/hooks.ts";
34+
import type { HookEvent_Discord } from "../claude/hooks.ts";
3335
import { THINKING_MODES, OPERATION_MODES, EFFORT_LEVELS } from "../settings/index.ts";
3436

3537
import type { ShellManager } from "../shell/index.ts";
@@ -433,7 +435,10 @@ export function createAllHandlers(
433435
if (s.enableFileCheckpointing) {
434436
opts.enableFileCheckpointing = true;
435437
}
436-
if (s.enableSandbox) {
438+
// Sandbox — granular config takes precedence over simple enableSandbox toggle
439+
if (s.sandboxConfig) {
440+
opts.sandbox = s.sandboxConfig;
441+
} else if (s.enableSandbox) {
437442
opts.sandbox = { enabled: true, autoAllowBashIfSandboxed: true };
438443
}
439444
if (s.enableAgentTeams) {
@@ -442,6 +447,44 @@ export function createAllHandlers(
442447
if (s.outputJsonSchema) {
443448
opts.outputFormat = { type: 'json_schema', schema: s.outputJsonSchema };
444449
}
450+
// Additional directories for multi-repo access
451+
if (s.additionalDirectories && s.additionalDirectories.length > 0) {
452+
opts.additionalDirectories = s.additionalDirectories;
453+
}
454+
455+
// Hooks — passive SDK callbacks for tool/notification/task observability
456+
if (s.hooksLogToolUse || s.hooksLogNotifications || s.hooksLogTaskCompletions) {
457+
const hookEventToMessage = (event: HookEvent_Discord): void => {
458+
const prefix = '🪝';
459+
let content = '';
460+
switch (event.type) {
461+
case 'tool_start':
462+
content = `${prefix} Tool started: **${event.toolName}**`;
463+
break;
464+
case 'tool_complete':
465+
content = `${prefix} Tool completed: **${event.toolName}**`;
466+
break;
467+
case 'tool_failure':
468+
content = `${prefix} Tool failed: **${event.toolName}** — ${event.error ?? 'unknown error'}`;
469+
break;
470+
case 'notification':
471+
content = `${prefix} Notification: ${event.title ? `**${event.title}** — ` : ''}${event.message ?? ''}`;
472+
break;
473+
case 'task_completed':
474+
content = `${prefix} Task completed: ${event.taskSubject ?? event.taskId ?? 'unknown'}`;
475+
break;
476+
}
477+
if (content) {
478+
sendClaudeMessages([{ type: 'system', content }]);
479+
}
480+
};
481+
opts.hooks = buildHooks({
482+
logToolUse: s.hooksLogToolUse,
483+
logNotifications: s.hooksLogNotifications,
484+
logTaskCompletions: s.hooksLogTaskCompletions,
485+
onHookEvent: hookEventToMessage,
486+
});
487+
}
445488

446489
// AskUserQuestion — interactive question flow (late-bound from index.ts)
447490
if (deps.onAskUser) {

0 commit comments

Comments
 (0)