@agentstep/agent-sdk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/package.json +45 -0
  2. package/src/auth/middleware.ts +38 -0
  3. package/src/backends/claude/args.ts +88 -0
  4. package/src/backends/claude/index.ts +193 -0
  5. package/src/backends/claude/permission-hook.ts +152 -0
  6. package/src/backends/claude/tool-bridge.ts +211 -0
  7. package/src/backends/claude/translator.ts +209 -0
  8. package/src/backends/claude/wrapper-script.ts +45 -0
  9. package/src/backends/codex/args.ts +69 -0
  10. package/src/backends/codex/auth.ts +35 -0
  11. package/src/backends/codex/index.ts +57 -0
  12. package/src/backends/codex/setup.ts +37 -0
  13. package/src/backends/codex/translator.ts +223 -0
  14. package/src/backends/codex/wrapper-script.ts +26 -0
  15. package/src/backends/factory/args.ts +45 -0
  16. package/src/backends/factory/auth.ts +30 -0
  17. package/src/backends/factory/index.ts +56 -0
  18. package/src/backends/factory/setup.ts +34 -0
  19. package/src/backends/factory/translator.ts +139 -0
  20. package/src/backends/factory/wrapper-script.ts +33 -0
  21. package/src/backends/gemini/args.ts +44 -0
  22. package/src/backends/gemini/auth.ts +30 -0
  23. package/src/backends/gemini/index.ts +53 -0
  24. package/src/backends/gemini/setup.ts +34 -0
  25. package/src/backends/gemini/translator.ts +139 -0
  26. package/src/backends/gemini/wrapper-script.ts +26 -0
  27. package/src/backends/opencode/args.ts +53 -0
  28. package/src/backends/opencode/auth.ts +53 -0
  29. package/src/backends/opencode/index.ts +70 -0
  30. package/src/backends/opencode/mcp.ts +67 -0
  31. package/src/backends/opencode/setup.ts +54 -0
  32. package/src/backends/opencode/translator.ts +168 -0
  33. package/src/backends/opencode/wrapper-script.ts +46 -0
  34. package/src/backends/registry.ts +38 -0
  35. package/src/backends/shared/ndjson.ts +29 -0
  36. package/src/backends/shared/translator-types.ts +69 -0
  37. package/src/backends/shared/wrap-prompt.ts +17 -0
  38. package/src/backends/types.ts +85 -0
  39. package/src/config/index.ts +95 -0
  40. package/src/db/agents.ts +185 -0
  41. package/src/db/api_keys.ts +78 -0
  42. package/src/db/batch.ts +142 -0
  43. package/src/db/client.ts +81 -0
  44. package/src/db/environments.ts +127 -0
  45. package/src/db/events.ts +208 -0
  46. package/src/db/memory.ts +143 -0
  47. package/src/db/migrations.ts +295 -0
  48. package/src/db/proxy.ts +37 -0
  49. package/src/db/sessions.ts +295 -0
  50. package/src/db/vaults.ts +110 -0
  51. package/src/errors.ts +53 -0
  52. package/src/handlers/agents.ts +194 -0
  53. package/src/handlers/batch.ts +41 -0
  54. package/src/handlers/docs.ts +87 -0
  55. package/src/handlers/environments.ts +154 -0
  56. package/src/handlers/events.ts +234 -0
  57. package/src/handlers/index.ts +12 -0
  58. package/src/handlers/memory.ts +141 -0
  59. package/src/handlers/openapi.ts +14 -0
  60. package/src/handlers/sessions.ts +223 -0
  61. package/src/handlers/stream.ts +76 -0
  62. package/src/handlers/threads.ts +26 -0
  63. package/src/handlers/ui/app.js +984 -0
  64. package/src/handlers/ui/index.html +112 -0
  65. package/src/handlers/ui/style.css +164 -0
  66. package/src/handlers/ui.ts +1281 -0
  67. package/src/handlers/vaults.ts +99 -0
  68. package/src/http.ts +35 -0
  69. package/src/index.ts +104 -0
  70. package/src/init.ts +227 -0
  71. package/src/openapi/registry.ts +8 -0
  72. package/src/openapi/schemas.ts +625 -0
  73. package/src/openapi/spec.ts +691 -0
  74. package/src/providers/apple.ts +220 -0
  75. package/src/providers/daytona.ts +217 -0
  76. package/src/providers/docker.ts +264 -0
  77. package/src/providers/e2b.ts +203 -0
  78. package/src/providers/fly.ts +276 -0
  79. package/src/providers/modal.ts +222 -0
  80. package/src/providers/podman.ts +206 -0
  81. package/src/providers/registry.ts +28 -0
  82. package/src/providers/shared.ts +11 -0
  83. package/src/providers/sprites.ts +55 -0
  84. package/src/providers/types.ts +73 -0
  85. package/src/providers/vercel.ts +208 -0
  86. package/src/proxy/forward.ts +111 -0
  87. package/src/queue/index.ts +111 -0
  88. package/src/sessions/actor.ts +53 -0
  89. package/src/sessions/bus.ts +155 -0
  90. package/src/sessions/driver.ts +818 -0
  91. package/src/sessions/grader.ts +120 -0
  92. package/src/sessions/interrupt.ts +14 -0
  93. package/src/sessions/sweeper.ts +136 -0
  94. package/src/sessions/threads.ts +126 -0
  95. package/src/sessions/tools.ts +50 -0
  96. package/src/shutdown.ts +78 -0
  97. package/src/sprite/client.ts +294 -0
  98. package/src/sprite/exec.ts +161 -0
  99. package/src/sprite/lifecycle.ts +339 -0
  100. package/src/sprite/pool.ts +65 -0
  101. package/src/sprite/setup.ts +159 -0
  102. package/src/state.ts +61 -0
  103. package/src/types.ts +339 -0
  104. package/src/util/clock.ts +7 -0
  105. package/src/util/ids.ts +11 -0
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "license": "Apache-2.0",
3
+ "name": "@agentstep/agent-sdk",
4
+ "version": "0.1.0",
5
+ "private": false,
6
+ "type": "module",
7
+ "main": "src/index.ts",
8
+ "types": "src/index.ts",
9
+ "exports": {
10
+ ".": "./src/index.ts",
11
+ "./queue": "./src/queue/index.ts",
12
+ "./config": "./src/config/index.ts",
13
+ "./backends/claude": "./src/backends/claude/index.ts",
14
+ "./backends/codex": "./src/backends/codex/index.ts",
15
+ "./backends/opencode": "./src/backends/opencode/index.ts",
16
+ "./handlers": "./src/handlers/index.ts",
17
+ "./*": "./src/*.ts"
18
+ },
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "https://github.com/agentstep/gateway.git",
22
+ "directory": "packages/agent-sdk"
23
+ },
24
+ "files": [
25
+ "src"
26
+ ],
27
+ "scripts": {
28
+ "test": "vitest run",
29
+ "test:watch": "vitest",
30
+ "typecheck": "tsc --noEmit"
31
+ },
32
+ "dependencies": {
33
+ "@asteasolutions/zod-to-openapi": "^7.3.4",
34
+ "libsql": "^0.5.29",
35
+ "ulid": "^2.3.0",
36
+ "ws": "^8.18.0",
37
+ "zod": "^3.23.8"
38
+ },
39
+ "devDependencies": {
40
+ "@types/node": "^24.0.0",
41
+ "@types/ws": "^8.5.13",
42
+ "typescript": "^5.9.3",
43
+ "vitest": "^2.1.0"
44
+ }
45
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Authentication middleware.
3
+ *
4
+ * Extracts an API key from `x-api-key` (preferred per Managed Agents spec)
5
+ * or `Authorization: Bearer <token>`. Hashes with sha256 and looks it up in
6
+ * the `api_keys` table. Returns an AuthContext on success.
7
+ *
8
+ * Simplified rewrite of
9
+ * —
10
+ * no WorkOS, no grace cache, no admin bypass key.
11
+ */
12
+ import { findByRawKey } from "../db/api_keys";
13
+ import type { AuthContext } from "../types";
14
+ import { unauthorized } from "../errors";
15
+
16
+ export function extractKey(request: Request): string | null {
17
+ const xKey = request.headers.get("x-api-key");
18
+ if (xKey && xKey.length > 0) return xKey;
19
+
20
+ const auth = request.headers.get("authorization") || request.headers.get("Authorization");
21
+ if (!auth) return null;
22
+ const m = /^Bearer\s+(.+)$/i.exec(auth);
23
+ return m ? m[1] : null;
24
+ }
25
+
26
+ export async function authenticate(request: Request): Promise<AuthContext> {
27
+ const key = extractKey(request);
28
+ if (!key) throw unauthorized();
29
+
30
+ const row = findByRawKey(key);
31
+ if (!row) throw unauthorized();
32
+
33
+ return {
34
+ keyId: row.id,
35
+ name: row.name,
36
+ permissions: JSON.parse(row.permissions_json) as string[],
37
+ };
38
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Build the `claude -p` argv for one turn.
3
+ *
4
+ * Collapses multi-CLI
5
+ *
6
+ * down to the claude-only shape this service needs.
7
+ *
8
+ * Always emits:
9
+ * -p --output-format stream-json --verbose
10
+ * --permission-mode bypassPermissions --max-turns <N>
11
+ *
12
+ * Adds `--resume <claude_session_id>` on every turn ≥ 2 using the most
13
+ * recently observed id from the previous turn's `system.init`.
14
+ */
15
+ import { getConfig } from "../../config";
16
+ import type { Agent, McpServerConfig } from "../../types";
17
+ import { resolveToolset } from "../../sessions/tools";
18
+
19
+ export interface BuildArgsInput {
20
+ agent: Agent;
21
+ claudeSessionId?: string | null;
22
+ maxTurns?: number;
23
+ confirmationMode?: boolean;
24
+ }
25
+
26
+ export function buildClaudeArgs(input: BuildArgsInput): string[] {
27
+ const cfg = getConfig();
28
+ const permissionMode = input.confirmationMode ? "default" : "bypassPermissions";
29
+ const argv: string[] = [
30
+ "-p",
31
+ "--output-format",
32
+ "stream-json",
33
+ "--verbose",
34
+ "--permission-mode",
35
+ permissionMode,
36
+ "--max-turns",
37
+ String(input.maxTurns ?? cfg.agentMaxTurns),
38
+ ];
39
+
40
+ if (input.claudeSessionId) {
41
+ argv.push("--resume", input.claudeSessionId);
42
+ }
43
+
44
+ if (input.agent.system) {
45
+ argv.push("--system-prompt", input.agent.system);
46
+ }
47
+
48
+ if (input.agent.model) {
49
+ argv.push("--model", input.agent.model);
50
+ }
51
+
52
+ const tools = resolveToolset(input.agent.tools);
53
+ if (tools.allowedTools.length) {
54
+ argv.push("--allowed-tools", tools.allowedTools.join(","));
55
+ }
56
+ if (tools.disallowedTools.length) {
57
+ argv.push("--disallowed-tools", tools.disallowedTools.join(","));
58
+ }
59
+
60
+ if (input.agent.mcp_servers && Object.keys(input.agent.mcp_servers).length > 0) {
61
+ argv.push(
62
+ "--mcp-config",
63
+ JSON.stringify({ mcpServers: input.agent.mcp_servers satisfies Record<string, McpServerConfig> }),
64
+ );
65
+ }
66
+
67
+ return argv;
68
+ }
69
+
70
+ /**
71
+ * Return the auth env vars for claude as a key-value map. The driver
72
+ * composes these into the wrapper stdin as `KEY=value` lines.
73
+ */
74
+ export function buildClaudeAuthEnv(): Record<string, string> {
75
+ const cfg = getConfig();
76
+ const env: Record<string, string> = {};
77
+
78
+ const token = cfg.anthropicApiKey || cfg.claudeToken;
79
+ if (token) {
80
+ if (token.startsWith("sk-ant-oat")) {
81
+ env.CLAUDE_CODE_OAUTH_TOKEN = token;
82
+ } else {
83
+ env.ANTHROPIC_API_KEY = token;
84
+ }
85
+ }
86
+
87
+ return env;
88
+ }
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Claude backend: drives `claude -p` on sprites.dev containers.
3
+ *
4
+ * Implements the Backend interface over the existing claude-specific
5
+ * args/translator/wrapper modules. `buildTurn` owns the stream-json
6
+ * tool_result re-entry path — if `toolResults` is non-empty, it builds a
7
+ * `{type: "user", message: {role, content: [tool_result, ...]}}` frame and
8
+ * flips the argv to include `--input-format stream-json`.
9
+ */
10
+ import { ApiError } from "../../errors";
11
+ import { getConfig } from "../../config";
12
+ import type { CustomTool } from "../../types";
13
+ import type { ContainerProvider } from "../../providers/types";
14
+ import type { Backend, BuildTurnInput, BuildTurnResult } from "../types";
15
+ import type { TranslatorOptions } from "../shared/translator-types";
16
+ import { buildClaudeArgs, buildClaudeAuthEnv } from "./args";
17
+ import { createClaudeTranslator } from "./translator";
18
+ import { CLAUDE_WRAPPER_PATH, installClaudeWrapper } from "./wrapper-script";
19
+ import {
20
+ generateBridgeScript,
21
+ buildBridgeMcpConfig,
22
+ toolsToJson,
23
+ TOOL_BRIDGE_DIR,
24
+ TOOL_BRIDGE_SCRIPT_PATH,
25
+ TOOL_BRIDGE_TOOLS_PATH,
26
+ } from "./tool-bridge";
27
+ import {
28
+ generatePermissionHookScript,
29
+ buildPermissionHooksConfig,
30
+ PERMISSION_BRIDGE_DIR,
31
+ PERMISSION_HOOK_SCRIPT_PATH,
32
+ } from "./permission-hook";
33
+
34
+ function buildTurn(input: BuildTurnInput): BuildTurnResult {
35
+ const { agent, backendSessionId, promptText, toolResults } = input;
36
+
37
+ const argsBase = buildClaudeArgs({
38
+ agent,
39
+ claudeSessionId: backendSessionId,
40
+ confirmationMode: agent.confirmation_mode,
41
+ });
42
+ const env = buildClaudeAuthEnv();
43
+
44
+ // If the agent has custom tools or threads_enabled (which adds spawn_agent
45
+ // as a bridge tool), inject the tool bridge MCP server into --mcp-config.
46
+ // The bridge script + tools.json are installed on the sprite by prepareOnSprite.
47
+ // This overrides any existing --mcp-config in argsBase — we find it and merge.
48
+ const customTools = agent.tools.filter((t): t is CustomTool => t.type === "custom");
49
+ const hasBridgeTools = customTools.length > 0 || agent.threads_enabled;
50
+ if (hasBridgeTools) {
51
+ const mcpIdx = argsBase.indexOf("--mcp-config");
52
+ let existingServers: Record<string, unknown> = {};
53
+ if (mcpIdx >= 0 && mcpIdx + 1 < argsBase.length) {
54
+ try {
55
+ const existing = JSON.parse(argsBase[mcpIdx + 1]) as { mcpServers?: Record<string, unknown> };
56
+ existingServers = existing.mcpServers ?? {};
57
+ } catch {}
58
+ // Remove the old --mcp-config pair
59
+ argsBase.splice(mcpIdx, 2);
60
+ }
61
+ const merged = buildBridgeMcpConfig(existingServers);
62
+ argsBase.push("--mcp-config", JSON.stringify({ mcpServers: merged }));
63
+ }
64
+
65
+ if (toolResults.length > 0) {
66
+ // Stream-json re-entry: claude accepts a user frame on stdin that mixes
67
+ // text and tool_result content blocks. Spike S5 verified this works with
68
+ // --resume + --input-format stream-json.
69
+ const args = [...argsBase, "--input-format", "stream-json"];
70
+ const content: Array<Record<string, unknown>> = [];
71
+ if (promptText) {
72
+ content.push({ type: "text", text: promptText });
73
+ }
74
+ for (const r of toolResults) {
75
+ content.push({
76
+ type: "tool_result",
77
+ tool_use_id: r.custom_tool_use_id,
78
+ content: r.content,
79
+ });
80
+ }
81
+ const userFrame = JSON.stringify({
82
+ type: "user",
83
+ message: { role: "user", content },
84
+ });
85
+ return { argv: args, env, stdin: userFrame };
86
+ }
87
+
88
+ return { argv: argsBase, env, stdin: promptText };
89
+ }
90
+
91
+ function validateRuntime(): string | null {
92
+ const cfg = getConfig();
93
+ if (!cfg.anthropicApiKey && !cfg.claudeToken) {
94
+ return "claude backend requires ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN to be set";
95
+ }
96
+ return null;
97
+ }
98
+
99
+ /**
100
+ * Install the bridge script and tools.json on the sprite if the agent has
101
+ * custom tools. Called from the lifecycle after the base prepareOnSprite.
102
+ */
103
+ async function installToolBridge(
104
+ spriteName: string,
105
+ customTools: CustomTool[],
106
+ provider: ContainerProvider,
107
+ ): Promise<void> {
108
+ if (customTools.length === 0) return;
109
+
110
+ await provider.exec(spriteName, ["mkdir", "-p", TOOL_BRIDGE_DIR]);
111
+ await provider.exec(
112
+ spriteName,
113
+ ["bash", "-c", `cat > ${TOOL_BRIDGE_SCRIPT_PATH}`],
114
+ { stdin: generateBridgeScript() },
115
+ );
116
+ await provider.exec(
117
+ spriteName,
118
+ ["bash", "-c", `cat > ${TOOL_BRIDGE_TOOLS_PATH}`],
119
+ { stdin: toolsToJson(customTools) },
120
+ );
121
+ await provider.exec(spriteName, ["chmod", "+x", TOOL_BRIDGE_SCRIPT_PATH]);
122
+ }
123
+
124
+ /**
125
+ * Install the permission hook script and configure Claude Code's settings
126
+ * to use it. Called from the lifecycle after prepareOnSprite when the agent
127
+ * has confirmation_mode enabled.
128
+ */
129
+ async function installPermissionHook(
130
+ spriteName: string,
131
+ provider: ContainerProvider,
132
+ ): Promise<void> {
133
+ // Create the bridge directory
134
+ await provider.exec(spriteName, ["mkdir", "-p", PERMISSION_BRIDGE_DIR]);
135
+
136
+ // Write the hook script
137
+ await provider.exec(
138
+ spriteName,
139
+ ["bash", "-c", `cat > ${PERMISSION_HOOK_SCRIPT_PATH}`],
140
+ { stdin: generatePermissionHookScript() },
141
+ );
142
+ await provider.exec(spriteName, ["chmod", "+x", PERMISSION_HOOK_SCRIPT_PATH]);
143
+
144
+ // Write the hooks config to $HOME/.claude/settings.json.
145
+ // Claude Code reads hooks from the user's settings file at startup.
146
+ // We need to merge with any existing settings (e.g. from prior setup).
147
+ const hooksConfig = buildPermissionHooksConfig();
148
+ const settingsPath = "/home/sprite/.claude/settings.json";
149
+
150
+ // Read existing settings if any, merge hooks config
151
+ let existingSettings: Record<string, unknown> = {};
152
+ try {
153
+ const result = await provider.exec(
154
+ spriteName,
155
+ ["cat", settingsPath],
156
+ );
157
+ if (result.stdout.trim()) {
158
+ existingSettings = JSON.parse(result.stdout) as Record<string, unknown>;
159
+ }
160
+ } catch {
161
+ // No existing settings — that's fine
162
+ }
163
+
164
+ const merged = { ...existingSettings, ...hooksConfig };
165
+ await provider.exec(
166
+ spriteName,
167
+ ["bash", "-c", `mkdir -p /home/sprite/.claude && cat > ${settingsPath}`],
168
+ { stdin: JSON.stringify(merged, null, 2) },
169
+ );
170
+ }
171
+
172
+ export const claudeBackend: Backend = {
173
+ name: "claude",
174
+ wrapperPath: CLAUDE_WRAPPER_PATH,
175
+ buildTurn,
176
+ createTranslator: (opts: TranslatorOptions) => createClaudeTranslator(opts),
177
+ prepareOnSprite: (name: string, provider: ContainerProvider) => installClaudeWrapper(name, provider),
178
+ validateRuntime,
179
+ };
180
+
181
+ // Re-export utilities needed by tests or other modules that historically
182
+ // imported them from lib/claude/*.
183
+ export {
184
+ buildClaudeArgs,
185
+ buildClaudeAuthEnv,
186
+ createClaudeTranslator,
187
+ installClaudeWrapper,
188
+ installToolBridge,
189
+ installPermissionHook,
190
+ CLAUDE_WRAPPER_PATH,
191
+ };
192
+ // Re-export ApiError so the driver doesn't need to handle this indirection
193
+ export { ApiError };
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Permission hook bridge: generates a Node.js hook script that bridges
3
+ * Claude Code's PermissionRequest hook to the Managed Agents API's
4
+ * `user.tool_confirmation` flow.
5
+ *
6
+ * Architecture:
7
+ * - A Node.js script installed at /tmp/permission-bridge/hook.mjs
8
+ * - Claude Code fires PermissionRequest hooks when running in
9
+ * `--permission-mode default` and a tool needs approval
10
+ * - The hook receives the permission request JSON on stdin
11
+ * - It writes /tmp/permission-bridge/request.json with tool details
12
+ * - Creates /tmp/permission-bridge/pending sentinel
13
+ * - Blocks polling for /tmp/permission-bridge/response.json via fs.watchFile
14
+ * - On response: outputs the hook result JSON to stdout and cleans up
15
+ *
16
+ * The driver detects the pending sentinel via a background poller during
17
+ * the stream loop. When found, it emits `agent.tool_confirmation_request`
18
+ * on the event bus and waits for the client to POST `user.tool_confirmation`.
19
+ * The events route writes response.json into the container, unblocking the hook.
20
+ */
21
+
22
+ export const PERMISSION_BRIDGE_DIR = "/tmp/permission-bridge";
23
+ export const PERMISSION_HOOK_SCRIPT_PATH = `${PERMISSION_BRIDGE_DIR}/hook.mjs`;
24
+ export const PERMISSION_BRIDGE_REQUEST_PATH = `${PERMISSION_BRIDGE_DIR}/request.json`;
25
+ export const PERMISSION_BRIDGE_RESPONSE_PATH = `${PERMISSION_BRIDGE_DIR}/response.json`;
26
+ export const PERMISSION_BRIDGE_PENDING_PATH = `${PERMISSION_BRIDGE_DIR}/pending`;
27
+
28
+ /**
29
+ * Generate the PermissionRequest hook script as a string.
30
+ * This script is written to the container and referenced from
31
+ * $HOME/.claude/settings.json hooks config.
32
+ */
33
+ export function generatePermissionHookScript(): string {
34
+ return `#!/usr/bin/env node
35
+ // Auto-generated PermissionRequest hook for tool confirmation bridge.
36
+ // Reads hook JSON from stdin, writes request.json + pending sentinel,
37
+ // polls for response.json, then outputs the hook response to stdout.
38
+ import { readFileSync, writeFileSync, unlinkSync, existsSync, watchFile, unwatchFile } from 'node:fs';
39
+
40
+ const REQUEST_PATH = ${JSON.stringify(PERMISSION_BRIDGE_REQUEST_PATH)};
41
+ const RESPONSE_PATH = ${JSON.stringify(PERMISSION_BRIDGE_RESPONSE_PATH)};
42
+ const PENDING_PATH = ${JSON.stringify(PERMISSION_BRIDGE_PENDING_PATH)};
43
+ const TIMEOUT_MS = 120000; // 2 minutes
44
+
45
+ // Read hook input from stdin
46
+ let input = '';
47
+ process.stdin.setEncoding('utf8');
48
+ process.stdin.on('data', (chunk) => { input += chunk; });
49
+ process.stdin.on('end', () => {
50
+ let hookInput;
51
+ try {
52
+ hookInput = JSON.parse(input);
53
+ } catch (e) {
54
+ // If we can't parse the input, allow by default to avoid blocking
55
+ console.error('[permission-hook] failed to parse stdin:', e.message);
56
+ outputResult({ behavior: 'allow' });
57
+ return;
58
+ }
59
+
60
+ // Extract tool details from the PermissionRequest hook payload.
61
+ // Claude Code sends: { tool_name, tool_input, tool_use_id, ... }
62
+ const toolName = hookInput.tool_name || hookInput.toolName || 'unknown';
63
+ const toolInput = hookInput.tool_input || hookInput.toolInput || {};
64
+ const toolUseId = hookInput.tool_use_id || hookInput.toolUseId || '';
65
+
66
+ // Write request.json with tool details
67
+ writeFileSync(REQUEST_PATH, JSON.stringify({
68
+ tool_name: toolName,
69
+ tool_input: toolInput,
70
+ tool_use_id: toolUseId,
71
+ }));
72
+
73
+ // Create pending sentinel
74
+ writeFileSync(PENDING_PATH, '');
75
+
76
+ // Poll for response.json
77
+ const startTime = Date.now();
78
+ let resolved = false;
79
+
80
+ const checkResponse = () => {
81
+ if (resolved) return;
82
+ if (!existsSync(RESPONSE_PATH)) return;
83
+ resolved = true;
84
+ try { unwatchFile(RESPONSE_PATH, pollFn); } catch {}
85
+ if (timeoutTimer) clearTimeout(timeoutTimer);
86
+
87
+ try {
88
+ const resp = JSON.parse(readFileSync(RESPONSE_PATH, 'utf8'));
89
+ try { unlinkSync(RESPONSE_PATH); } catch {}
90
+ try { unlinkSync(PENDING_PATH); } catch {}
91
+
92
+ if (resp.result === 'allow') {
93
+ outputResult({ behavior: 'allow' });
94
+ } else {
95
+ outputResult({ behavior: 'deny', message: resp.deny_message || 'User denied tool use' });
96
+ }
97
+ } catch (e) {
98
+ console.error('[permission-hook] failed to read response:', e.message);
99
+ // On error, deny to be safe
100
+ try { unlinkSync(PENDING_PATH); } catch {}
101
+ outputResult({ behavior: 'deny', message: 'Permission hook error: ' + e.message });
102
+ }
103
+ };
104
+
105
+ const pollFn = () => checkResponse();
106
+
107
+ // Check immediately in case response was pre-written
108
+ checkResponse();
109
+ if (resolved) return;
110
+
111
+ // Use fs.watchFile for reliable polling
112
+ watchFile(RESPONSE_PATH, { interval: 200 }, pollFn);
113
+
114
+ // Timeout: deny after TIMEOUT_MS
115
+ const timeoutTimer = setTimeout(() => {
116
+ if (resolved) return;
117
+ resolved = true;
118
+ try { unwatchFile(RESPONSE_PATH, pollFn); } catch {}
119
+ try { unlinkSync(PENDING_PATH); } catch {}
120
+ outputResult({ behavior: 'deny', message: 'Permission request timed out after ' + (TIMEOUT_MS / 1000) + 's' });
121
+ }, TIMEOUT_MS);
122
+ });
123
+
124
+ function outputResult(decision) {
125
+ const output = JSON.stringify({
126
+ hookSpecificOutput: {
127
+ hookEventName: 'PermissionRequest',
128
+ decision: decision,
129
+ },
130
+ });
131
+ process.stdout.write(output);
132
+ process.exit(0);
133
+ }
134
+ `;
135
+ }
136
+
137
+ /**
138
+ * Build the settings.json hooks configuration for the permission hook.
139
+ * Returns the JSON object to merge into $HOME/.claude/settings.json.
140
+ */
141
+ export function buildPermissionHooksConfig(): Record<string, unknown> {
142
+ return {
143
+ hooks: {
144
+ PermissionRequest: [
145
+ {
146
+ type: "command",
147
+ command: `node ${PERMISSION_HOOK_SCRIPT_PATH}`,
148
+ },
149
+ ],
150
+ },
151
+ };
152
+ }