@dex-ai/coding-agent-sdk 0.1.21

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.
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@dex-ai/coding-agent-sdk",
3
+ "version": "0.1.21",
4
+ "description": "Coding agent SDK — extensions for workspace, config, approval, session.",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./src/index.ts",
9
+ "default": "./src/index.ts"
10
+ }
11
+ },
12
+ "files": [
13
+ "src"
14
+ ],
15
+ "scripts": {
16
+ "typecheck": "tsc --noEmit"
17
+ },
18
+ "dependencies": {
19
+ "@dex-ai/sdk": "^0.1.18",
20
+ "@dex-ai/core-extensions": "^0.1.5",
21
+ "zod": "^3.23.8"
22
+ },
23
+ "sideEffects": false,
24
+ "publishConfig": {
25
+ "access": "public",
26
+ "registry": "https://registry.npmjs.org/"
27
+ }
28
+ }
package/src/create.ts ADDED
@@ -0,0 +1,76 @@
1
+ /**
2
+ * CodingAgent.create() — composes SDK extensions into a ready-to-use coding agent.
3
+ */
4
+
5
+ import { Agent } from "@dex-ai/sdk";
6
+ import type { Agent as AgentType } from "@dex-ai/sdk";
7
+ import type { CodingAgentOptions } from "./types";
8
+
9
+ import { workspaceExtension } from "./extensions/workspace";
10
+ import { configExtension } from "./extensions/config";
11
+ import { settingsExtension } from "./extensions/settings";
12
+ import { approvalExtension } from "./extensions/approval";
13
+ import { sessionExtension } from "./extensions/session";
14
+ import { envExtension } from "./extensions/env";
15
+ import { buildSystemPrompt } from "./extensions/system-prompt";
16
+ import {
17
+ skillsExtension,
18
+ allFsExtensions,
19
+ bashExtension,
20
+ tasksExtension,
21
+ askExtension,
22
+ } from "@dex-ai/core-extensions";
23
+
24
+ export const CodingAgent = {
25
+ async create(opts: CodingAgentOptions): Promise<AgentType> {
26
+ const cwd = opts.cwd ?? process.cwd();
27
+
28
+ // Create env extension first — it provides getCwd() for tools
29
+ const env = envExtension({
30
+ cwd,
31
+ rootDirs: opts.rootDirs,
32
+ });
33
+
34
+ // Tools use the env's dynamic cwd getter so `cd` takes effect immediately
35
+ const cwdGetter = env.getCwd;
36
+ const rootsGetter = env.getRootDirs;
37
+
38
+ return Agent.create({
39
+ name: "dex-coding-agent",
40
+ provider: opts.provider,
41
+ model: opts.model,
42
+ systemPrompt: opts.systemPrompt ?? buildSystemPrompt(),
43
+ ...(opts.messages !== undefined ? { messages: opts.messages } : {}),
44
+ toolResultCache: { excludedTools: ["read"] },
45
+ extensions: [
46
+ // Provider(s)
47
+ opts.providerExtension,
48
+ ...(opts.providerExtensions ?? []),
49
+ // SDK extensions
50
+ env,
51
+ workspaceExtension({ cwd }),
52
+ configExtension(
53
+ opts.config !== undefined ? { config: opts.config } : {},
54
+ ),
55
+ settingsExtension(),
56
+ skillsExtension(),
57
+ ...(opts.onApproval
58
+ ? [approvalExtension({ handler: opts.onApproval })]
59
+ : []),
60
+ sessionExtension({
61
+ ...(opts.sessionId !== undefined
62
+ ? { sessionId: opts.sessionId }
63
+ : {}),
64
+ ...(opts.sessionDir !== undefined ? { dir: opts.sessionDir } : {}),
65
+ }),
66
+ // Tools — use dynamic cwd and roots from env extension
67
+ ...allFsExtensions({ cwd: cwdGetter, roots: rootsGetter }),
68
+ bashExtension({ cwd: cwdGetter }),
69
+ tasksExtension(),
70
+ askExtension(),
71
+ // Additional extensions from consumer
72
+ ...(opts.extensions ?? []),
73
+ ],
74
+ });
75
+ },
76
+ };
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Approval Extension — permission-mode-aware tool approval gate.
3
+ *
4
+ * Reads permission state from the "settings" extension (via actx.state).
5
+ *
6
+ * Permission modes:
7
+ * - 'read': read-access tools pass, all others require one-time session approval
8
+ * - 'auto': read tools pass + always-allowed tools pass, others require one-time session approval
9
+ * - 'yolo': all tools pass except denied tools (which are always blocked)
10
+ *
11
+ * The mode is loaded from the settings extension and can be changed at runtime
12
+ * (Shift+Tab in the TUI cycles modes via the settings extension).
13
+ */
14
+
15
+ import type { Extension, ToolCall, ToolResult, GenerateContext, AnyTool, AgentContext } from "@dex-ai/sdk";
16
+ import type {
17
+ ApprovalHandler,
18
+ ApprovalRequest,
19
+ ApprovalResult,
20
+ ToolCategory,
21
+ } from "../types";
22
+ import type { SettingsExtensionState } from "./settings";
23
+
24
+ export interface ApprovalExtensionOptions {
25
+ handler: ApprovalHandler;
26
+ }
27
+
28
+ function categorize(toolName: string): ToolCategory {
29
+ switch (toolName) {
30
+ case "read":
31
+ case "search":
32
+ return "safe";
33
+ case "write":
34
+ case "edit":
35
+ return "write";
36
+ case "bash":
37
+ return "execute";
38
+ default:
39
+ return "write";
40
+ }
41
+ }
42
+
43
+ function reasonForCategory(toolName: string, category: ToolCategory): string {
44
+ switch (category) {
45
+ case "safe":
46
+ return "read-only operation";
47
+ case "write":
48
+ return `writes to file system (${toolName})`;
49
+ case "execute":
50
+ return "executes a shell command";
51
+ case "dangerous":
52
+ return "potentially destructive operation";
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Resolve the access level for a tool by checking its metadata.
58
+ * Falls back to 'write' (conservative — requires approval).
59
+ */
60
+ function resolveAccess(toolName: string, extensions: ReadonlyArray<Extension>): "read" | "write" {
61
+ for (const ext of extensions) {
62
+ if (!ext.tools) continue;
63
+ const tools: ReadonlyArray<AnyTool> = Array.isArray(ext.tools)
64
+ ? ext.tools
65
+ : [ext.tools as AnyTool];
66
+ for (const t of tools) {
67
+ if (t.name === toolName) {
68
+ return t.access ?? "write";
69
+ }
70
+ }
71
+ }
72
+ return "write";
73
+ }
74
+
75
+ export function approvalExtension(opts: ApprovalExtensionOptions): Extension {
76
+ // Session-scoped approvals (cleared on session restart)
77
+ const sessionApproved = new Set<string>();
78
+
79
+ // References set during init
80
+ let allExtensions: ReadonlyArray<Extension> = [];
81
+ let settingsState: SettingsExtensionState | null = null;
82
+
83
+ return {
84
+ name: "approval",
85
+
86
+ init(actx: AgentContext) {
87
+ allExtensions = actx.extensions;
88
+ settingsState = actx.state.get("settings") as SettingsExtensionState | undefined ?? null;
89
+ },
90
+
91
+ on: {
92
+ "tool-start": async (
93
+ call: ToolCall,
94
+ _gctx: GenerateContext,
95
+ ): Promise<ToolCall | ToolResult | void> => {
96
+ // If no settings extension, fall back to allowing everything
97
+ if (!settingsState) return;
98
+
99
+ const mode = settingsState.permissionMode;
100
+ const toolAccess = resolveAccess(call.toolName, allExtensions);
101
+
102
+ // --- YOLO mode: allow everything except denied tools ---
103
+ if (mode === "yolo") {
104
+ if (settingsState.deniedTools.has(call.toolName)) {
105
+ return {
106
+ toolCallId: call.toolCallId,
107
+ toolName: call.toolName,
108
+ output: {
109
+ type: "error-text",
110
+ value: `Tool "${call.toolName}" is on the deny list.`,
111
+ },
112
+ };
113
+ }
114
+ return; // allow
115
+ }
116
+
117
+ // --- Read tools always pass in both 'read' and 'auto' modes ---
118
+ if (toolAccess === "read") return;
119
+
120
+ // --- Session-approved tools pass through (both modes) ---
121
+ if (sessionApproved.has(call.toolName)) return;
122
+
123
+ // --- AUTO mode: also check the always-allowed list ---
124
+ if (mode === "auto") {
125
+ if (settingsState.allowedTools.has(call.toolName)) return;
126
+ }
127
+
128
+ // --- Need approval ---
129
+ const category = categorize(call.toolName);
130
+ const request: ApprovalRequest = {
131
+ toolName: call.toolName,
132
+ toolCallId: call.toolCallId,
133
+ input: call.input,
134
+ category,
135
+ reason: reasonForCategory(call.toolName, category),
136
+ };
137
+
138
+ const result = await opts.handler(request);
139
+
140
+ switch (result.decision) {
141
+ case "allow":
142
+ return; // one-time allow
143
+ case "allow-session":
144
+ sessionApproved.add(call.toolName);
145
+ return;
146
+ case "allow-always":
147
+ sessionApproved.add(call.toolName);
148
+ // Persist via settings extension
149
+ settingsState.allowAlways(call.toolName);
150
+ return;
151
+ case "deny": {
152
+ const reason =
153
+ result.reason ?? `User denied ${call.toolName} execution.`;
154
+ return {
155
+ toolCallId: call.toolCallId,
156
+ toolName: call.toolName,
157
+ output: { type: "error-text", value: reason },
158
+ };
159
+ }
160
+ }
161
+ },
162
+ },
163
+ };
164
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Config Extension — loads and merges configuration from file + env + overrides.
3
+ *
4
+ * Reads extension-specific config from:
5
+ * - (global) ~/.dex/extensions/config.json
6
+ * - (workspace) .dex/extensions/config.json
7
+ * - Environment variables: DEX_<KEY>
8
+ * - Explicit opts.config overrides
9
+ *
10
+ * The config file is keyed by extension name:
11
+ * { "workspace": { "maxDepth": 3 }, "openai": { "maxTokens": 4096 } }
12
+ */
13
+
14
+ import type { Extension, AgentContext } from "@dex-ai/sdk";
15
+ import { existsSync, readFileSync } from "node:fs";
16
+ import { join } from "node:path";
17
+ import { homedir } from "node:os";
18
+ import type { CodingAgentConfig, Workspace } from "../types";
19
+
20
+ export interface ConfigExtensionOptions {
21
+ config?: Partial<CodingAgentConfig>;
22
+ }
23
+
24
+ const GLOBAL_DEX_DIR = join(homedir(), ".dex");
25
+ const GLOBAL_EXTENSIONS_CONFIG = join(
26
+ GLOBAL_DEX_DIR,
27
+ "extensions",
28
+ "config.json",
29
+ );
30
+
31
+ /**
32
+ * Load JSON from a path, returning empty object on failure.
33
+ */
34
+ function loadJsonFile(path: string): Record<string, unknown> {
35
+ if (!existsSync(path)) return {};
36
+ try {
37
+ return JSON.parse(readFileSync(path, "utf-8")) as Record<string, unknown>;
38
+ } catch {
39
+ return {};
40
+ }
41
+ }
42
+
43
+ function loadEnvConfig(): Partial<CodingAgentConfig> {
44
+ const config: Partial<CodingAgentConfig> = {};
45
+ if (process.env.DEX_PROVIDER)
46
+ (config as any).provider = process.env.DEX_PROVIDER;
47
+ if (process.env.DEX_MODEL) (config as any).model = process.env.DEX_MODEL;
48
+ if (process.env.DEX_MAX_STEPS)
49
+ (config as any).maxSteps = parseInt(process.env.DEX_MAX_STEPS, 10);
50
+ if (process.env.DEX_MAX_TOKENS)
51
+ (config as any).maxTokens = parseInt(process.env.DEX_MAX_TOKENS, 10);
52
+ return config;
53
+ }
54
+
55
+ export function configExtension(opts: ConfigExtensionOptions = {}): Extension {
56
+ return {
57
+ name: "config",
58
+
59
+ init(actx: AgentContext) {
60
+ const workspace = actx.state.get("workspace") as Workspace | undefined;
61
+ const workspaceDexDir = workspace?.dexDir;
62
+
63
+ // Load extensions config: global → workspace (workspace overrides global)
64
+ const globalExtConfig = loadJsonFile(GLOBAL_EXTENSIONS_CONFIG);
65
+ const workspaceExtConfig = workspaceDexDir
66
+ ? loadJsonFile(join(workspaceDexDir, "extensions", "config.json"))
67
+ : {};
68
+
69
+ // Merge: global extensions config → workspace extensions config
70
+ const extensionsConfig = { ...globalExtConfig, ...workspaceExtConfig };
71
+
72
+ // Load env + explicit overrides for agent-level config
73
+ const fromEnv = loadEnvConfig();
74
+ const merged: CodingAgentConfig = {
75
+ provider: actx.providerName,
76
+ model: actx.modelId,
77
+ cwd: workspace?.cwd ?? process.cwd(),
78
+ ...fromEnv,
79
+ ...opts.config,
80
+ };
81
+
82
+ actx.state.set("config", merged);
83
+ actx.state.set("extensions-config", extensionsConfig);
84
+ },
85
+ };
86
+ }
@@ -0,0 +1,281 @@
1
+ /**
2
+ * Env Extension — manages runtime environment state for the coding agent.
3
+ *
4
+ * Responsibilities:
5
+ * - Maintains a list of rootDirs (sandbox boundaries). The CLI entry point
6
+ * is the first root; users can add more via /add-dir.
7
+ * - Maintains a mutable cwd that must reside within one of the rootDirs.
8
+ * - Provides a `cd` tool so the agent can change cwd at runtime.
9
+ * - Injects per-turn environment context (rootDirs, cwd, datetime) via
10
+ * model-start as a system message so the model always knows where it is.
11
+ * - Exposes getCwd() for other extensions/tools to read dynamically.
12
+ */
13
+
14
+ import type {
15
+ Extension,
16
+ AgentContext,
17
+ GenerateContext,
18
+ Content,
19
+ ModelRequest,
20
+ Message,
21
+ } from "@dex-ai/sdk";
22
+ import { resolve, sep } from "node:path";
23
+ import { realpathSync, existsSync, statSync } from "node:fs";
24
+ import { homedir } from "node:os";
25
+ import { z } from "zod";
26
+ import type { Tool, ToolOutput } from "@dex-ai/sdk";
27
+
28
+ /* ------------------------------------------------------------------ */
29
+ /* Types */
30
+ /* ------------------------------------------------------------------ */
31
+
32
+ export interface EnvExtensionOptions {
33
+ /** Initial working directory. Becomes the first rootDir. */
34
+ cwd: string;
35
+ /** Additional root directories to allow. Default: []. */
36
+ rootDirs?: string[] | undefined;
37
+ }
38
+
39
+ export interface EnvState {
40
+ /** Allowed root directories (sandbox boundaries). */
41
+ readonly rootDirs: string[];
42
+ /** Current working directory (mutable, always inside a rootDir). */
43
+ cwd: string;
44
+ }
45
+
46
+ /* ------------------------------------------------------------------ */
47
+ /* Helpers */
48
+ /* ------------------------------------------------------------------ */
49
+
50
+ function expandTilde(p: string): string {
51
+ if (p === "~") return homedir();
52
+ if (p.startsWith("~/") || p.startsWith("~" + sep)) {
53
+ return homedir() + p.slice(1);
54
+ }
55
+ return p;
56
+ }
57
+
58
+ function realDir(dir: string): string {
59
+ const resolved = resolve(expandTilde(dir));
60
+ try {
61
+ return realpathSync(resolved);
62
+ } catch {
63
+ return resolved;
64
+ }
65
+ }
66
+
67
+ function isInsideRoots(target: string, roots: string[]): boolean {
68
+ const realTarget = realDir(target);
69
+ for (const root of roots) {
70
+ const realRoot = realDir(root);
71
+ if (realTarget === realRoot || realTarget.startsWith(realRoot + sep)) {
72
+ return true;
73
+ }
74
+ }
75
+ return false;
76
+ }
77
+
78
+ /* ------------------------------------------------------------------ */
79
+ /* Extension */
80
+ /* ------------------------------------------------------------------ */
81
+
82
+ export function envExtension(opts: EnvExtensionOptions): Extension & {
83
+ /** Get the current working directory. Use as a cwd getter for tools. */
84
+ getCwd: () => string;
85
+ /** Get the list of allowed root directories. */
86
+ getRootDirs: () => ReadonlyArray<string>;
87
+ /** Add a root directory to the sandbox. Returns true if added successfully. */
88
+ addRootDir: (dir: string) => boolean;
89
+ } {
90
+ const initialCwd = realDir(opts.cwd);
91
+ const rootDirs: string[] = [initialCwd];
92
+
93
+ // Add any additional root dirs
94
+ if (opts.rootDirs) {
95
+ for (const d of opts.rootDirs) {
96
+ const rd = realDir(d);
97
+ if (!rootDirs.includes(rd)) {
98
+ rootDirs.push(rd);
99
+ }
100
+ }
101
+ }
102
+
103
+ let cwd = initialCwd;
104
+
105
+ const getCwd = () => cwd;
106
+ const getRootDirs = () => rootDirs as ReadonlyArray<string>;
107
+
108
+ const addRootDir = (dir: string): boolean => {
109
+ const rd = realDir(dir);
110
+ if (!existsSync(rd)) return false;
111
+ try {
112
+ if (!statSync(rd).isDirectory()) return false;
113
+ } catch {
114
+ return false;
115
+ }
116
+ if (!rootDirs.includes(rd)) {
117
+ rootDirs.push(rd);
118
+ }
119
+ return true;
120
+ };
121
+
122
+ /* ---------------------------------------------------------------- */
123
+ /* cd tool */
124
+ /* ---------------------------------------------------------------- */
125
+
126
+ const cdParamsSchema = z.object({
127
+ path: z
128
+ .string()
129
+ .min(1)
130
+ .describe(
131
+ "Directory to change to. Absolute or relative to current cwd. Must be within the sandbox (rootDirs).",
132
+ ),
133
+ });
134
+
135
+ type CdParams = z.infer<typeof cdParamsSchema>;
136
+
137
+ const cdTool: Tool<CdParams, string> = {
138
+ name: "cd",
139
+ displayName: "Cd",
140
+ access: "write",
141
+ description:
142
+ "Change the working directory. The new path must be within the allowed root directories (sandbox). All subsequent tool calls (read, write, edit, search, bash) will operate relative to the new cwd.",
143
+ parameters: cdParamsSchema,
144
+ async execute(input): Promise<ToolOutput> {
145
+ const target = resolve(cwd, input.path);
146
+ const realTarget = realDir(target);
147
+
148
+ // Validate target exists and is a directory
149
+ if (!existsSync(realTarget)) {
150
+ return {
151
+ type: "error-text",
152
+ value: `Directory not found: ${input.path}`,
153
+ };
154
+ }
155
+ try {
156
+ if (!statSync(realTarget).isDirectory()) {
157
+ return {
158
+ type: "error-text",
159
+ value: `Not a directory: ${input.path}`,
160
+ };
161
+ }
162
+ } catch {
163
+ return {
164
+ type: "error-text",
165
+ value: `Cannot access: ${input.path}`,
166
+ };
167
+ }
168
+
169
+ // Validate within sandbox
170
+ if (!isInsideRoots(realTarget, rootDirs)) {
171
+ return {
172
+ type: "error-text",
173
+ value: `Path outside sandbox: ${input.path}. Allowed roots: ${rootDirs.join(", ")}`,
174
+ };
175
+ }
176
+
177
+ cwd = realTarget;
178
+ return { type: "text", value: `cwd: ${cwd}` };
179
+ },
180
+ };
181
+
182
+ /* ---------------------------------------------------------------- */
183
+ /* model-start context injection */
184
+ /* ---------------------------------------------------------------- */
185
+
186
+ function buildEnvContext(actx: AgentContext): string {
187
+ const now = new Date();
188
+ const session = actx.state.get("session") as
189
+ | { id: string; path: string }
190
+ | undefined;
191
+ const lines = [
192
+ `<env>`,
193
+ ` cwd: ${cwd}`,
194
+ ` rootDirs: ${rootDirs.join(", ")}`,
195
+ ...(session ? [` sessionId: ${session.id}`] : []),
196
+ ` datetime: ${now.toISOString()}`,
197
+ ` platform: ${process.platform}/${process.arch}`,
198
+ `</env>`,
199
+ ];
200
+ return lines.join("\n");
201
+ }
202
+
203
+ /* ---------------------------------------------------------------- */
204
+ /* Extension definition */
205
+ /* ---------------------------------------------------------------- */
206
+
207
+ const ext: Extension & {
208
+ getCwd: () => string;
209
+ getRootDirs: () => ReadonlyArray<string>;
210
+ addRootDir: (dir: string) => boolean;
211
+ } = {
212
+ name: "env",
213
+ description: "Environment state: cwd, rootDirs, datetime, platform info.",
214
+
215
+ tools: cdTool,
216
+
217
+ init(actx: AgentContext) {
218
+ const state: EnvState = { rootDirs, cwd };
219
+ actx.state.set("env", state);
220
+ },
221
+
222
+ on: {
223
+ "model-start"(
224
+ req: ModelRequest,
225
+ gctx: GenerateContext,
226
+ ): ModelRequest | void {
227
+ // Sync state in case cwd changed since last generate
228
+ const actx = gctx.agent;
229
+ const state = actx.state.get("env") as EnvState | undefined;
230
+ if (state) {
231
+ state.cwd = cwd;
232
+ }
233
+
234
+ const text = buildEnvContext(actx);
235
+
236
+ // Inject as a system message after the first system prompt.
237
+ // This keeps it fresh (datetime updates each step) without
238
+ // creating a role-order violation like appending a user message.
239
+ const envMsg: Message = {
240
+ role: "system",
241
+ content: [{ type: "text" as const, text }],
242
+ };
243
+ const messages = [...req.messages];
244
+ // Insert after the first system message (or at index 0 if none)
245
+ const insertIdx = messages[0]?.role === "system" ? 1 : 0;
246
+ messages.splice(insertIdx, 0, envMsg);
247
+ return { ...req, messages };
248
+ },
249
+ },
250
+
251
+ // Expose methods for external use
252
+ getCwd,
253
+ getRootDirs,
254
+ addRootDir,
255
+
256
+ // Declare available commands for CLI discovery
257
+ commands: [
258
+ { name: "add-dir", description: "Add a directory to the sandbox" },
259
+ ],
260
+
261
+ // Handle /add-dir command (duck-typed for CLI host)
262
+ onCommand(name: string, args: string): boolean | string {
263
+ if (name === "add-dir") {
264
+ const dir = args.trim();
265
+ if (!dir) {
266
+ return "Usage: /add-dir <path>";
267
+ }
268
+ const expanded = expandTilde(dir);
269
+ const resolved = resolve(cwd, expanded);
270
+ const success = addRootDir(resolved);
271
+ if (!success) {
272
+ return `Failed to add directory: ${dir} (not found or not a directory)`;
273
+ }
274
+ return `✓ Added root directory: ${realDir(resolved)}`;
275
+ }
276
+ return false;
277
+ },
278
+ } as any;
279
+
280
+ return ext;
281
+ }