@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 +28 -0
- package/src/create.ts +76 -0
- package/src/extensions/approval.ts +164 -0
- package/src/extensions/config.ts +86 -0
- package/src/extensions/env.ts +281 -0
- package/src/extensions/session.ts +347 -0
- package/src/extensions/settings.ts +416 -0
- package/src/extensions/system-prompt.ts +208 -0
- package/src/extensions/workspace.ts +236 -0
- package/src/index.ts +45 -0
- package/src/types.ts +148 -0
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
|
+
}
|