@twinklerg/coden 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 (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +219 -0
  3. package/dist/index.js +21269 -0
  4. package/package.json +48 -0
  5. package/src/cli/agent-command.ts +497 -0
  6. package/src/cli/format.ts +42 -0
  7. package/src/cli/index.ts +149 -0
  8. package/src/cli/plugin-command.ts +217 -0
  9. package/src/config/config.ts +96 -0
  10. package/src/config/trust.ts +35 -0
  11. package/src/context/manager.ts +186 -0
  12. package/src/context/truncate.ts +9 -0
  13. package/src/core/events.ts +32 -0
  14. package/src/core/runtime.ts +402 -0
  15. package/src/core/types.ts +97 -0
  16. package/src/index.ts +14 -0
  17. package/src/observability/terminal.ts +201 -0
  18. package/src/observability/trace.ts +30 -0
  19. package/src/permissions/policy.ts +56 -0
  20. package/src/permissions/workspace.ts +139 -0
  21. package/src/plugins/api.ts +68 -0
  22. package/src/plugins/bun-package-manager.ts +35 -0
  23. package/src/plugins/installed-loader.ts +144 -0
  24. package/src/plugins/installer.ts +314 -0
  25. package/src/plugins/manifest.ts +89 -0
  26. package/src/plugins/package-manager.ts +10 -0
  27. package/src/plugins/package-metadata.ts +95 -0
  28. package/src/plugins/paths.ts +43 -0
  29. package/src/plugins/specifier.ts +63 -0
  30. package/src/plugins/transaction.ts +403 -0
  31. package/src/process/runner.ts +134 -0
  32. package/src/providers/anthropic.ts +117 -0
  33. package/src/providers/openai.ts +96 -0
  34. package/src/providers/scripted.ts +28 -0
  35. package/src/sessions/store.ts +278 -0
  36. package/src/tools/builtin/bash.ts +56 -0
  37. package/src/tools/builtin/edit.ts +42 -0
  38. package/src/tools/builtin/index.ts +9 -0
  39. package/src/tools/builtin/read.ts +91 -0
  40. package/src/tools/builtin/write.ts +34 -0
  41. package/src/tools/executor.ts +90 -0
  42. package/src/tools/plugin-loader.ts +122 -0
  43. package/src/tools/registry.ts +97 -0
@@ -0,0 +1,90 @@
1
+ import { truncateOutput } from "../context/truncate.js";
2
+ import type { EventBus } from "../core/events.js";
3
+ import type { ToolCall, ToolResult } from "../core/types.js";
4
+ import type { PermissionPolicy } from "../permissions/policy.js";
5
+ import { resolveWorkspacePath } from "../permissions/workspace.js";
6
+ import type { ToolRegistry } from "./registry.js";
7
+
8
+ export class ToolExecutor {
9
+ constructor(
10
+ private registry: ToolRegistry,
11
+ private readonly permissions: PermissionPolicy,
12
+ private readonly events: EventBus,
13
+ private readonly workspace: string,
14
+ private readonly timeoutMs = 60_000,
15
+ ) {}
16
+ setRegistry(registry: ToolRegistry): void {
17
+ this.registry = registry;
18
+ }
19
+ async execute(call: ToolCall, signal: AbortSignal, turnId?: string): Promise<ToolResult> {
20
+ await this.events.emit("tool.requested", { name: call.name, callId: call.callId }, turnId);
21
+ const tool = this.registry.get(call.name);
22
+ if (!tool) return { content: `tool.not_found: ${call.name}`, isError: true };
23
+ const validation = this.registry.validate(call.name, call.input);
24
+ if (!validation.valid)
25
+ return { content: `tool.invalid_input: ${validation.errors}`, isError: true };
26
+ if (["read", "write", "edit"].includes(call.name)) {
27
+ try {
28
+ await resolveWorkspacePath(this.workspace, (call.input as { path: string }).path);
29
+ } catch (error) {
30
+ return {
31
+ content: `permission.workspace_denied: ${error instanceof Error ? error.message : String(error)}`,
32
+ isError: true,
33
+ };
34
+ }
35
+ }
36
+ const permission = await this.permissions.authorize(tool, call, signal);
37
+ await this.events.emit(
38
+ "permission.requested",
39
+ { name: call.name, callId: call.callId, risk: permission.risk, allowed: permission.allowed },
40
+ turnId,
41
+ );
42
+ if (!permission.allowed)
43
+ return { content: `permission.denied: ${call.name} was not authorized`, isError: true };
44
+ await this.events.emit("tool.started", { name: call.name, callId: call.callId }, turnId);
45
+ const start = Date.now();
46
+ let result: ToolResult;
47
+ const controller = new AbortController();
48
+ const relayAbort = () => controller.abort(signal.reason);
49
+ if (signal.aborted) relayAbort();
50
+ else signal.addEventListener("abort", relayAbort, { once: true });
51
+ const timer = setTimeout(
52
+ () => controller.abort(new Error(`Tool timed out after ${this.timeoutMs}ms`)),
53
+ this.timeoutMs,
54
+ );
55
+ try {
56
+ const aborted = controller.signal.aborted
57
+ ? Promise.reject(controller.signal.reason)
58
+ : new Promise<never>((_, reject) => {
59
+ controller.signal.addEventListener("abort", () => reject(controller.signal.reason), {
60
+ once: true,
61
+ });
62
+ });
63
+ result = await Promise.race([
64
+ tool.execute(call.input, { workspace: this.workspace, signal: controller.signal }),
65
+ aborted,
66
+ ]);
67
+ result = { ...result, content: truncateOutput(result.content, 50_000) };
68
+ } catch (error) {
69
+ const timedOut = !signal.aborted && controller.signal.aborted;
70
+ result = {
71
+ content: `${timedOut ? "tool.timeout (abort requested; tools must cooperate)" : "tool.internal_error"}: ${error instanceof Error ? error.message : String(error)}`,
72
+ isError: true,
73
+ };
74
+ } finally {
75
+ clearTimeout(timer);
76
+ signal.removeEventListener("abort", relayAbort);
77
+ }
78
+ await this.events.emit(
79
+ "tool.completed",
80
+ {
81
+ name: call.name,
82
+ callId: call.callId,
83
+ isError: result.isError ?? false,
84
+ durationMs: Date.now() - start,
85
+ },
86
+ turnId,
87
+ );
88
+ return result;
89
+ }
90
+ }
@@ -0,0 +1,122 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { readdir, readFile, realpath, stat } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+ import type { EventBus } from "../core/events.js";
6
+ import type { ToolDefinition } from "../core/types.js";
7
+ import { ToolRegistry } from "./registry.js";
8
+
9
+ export type ProjectTrust = (realPluginDirectory: string) => Promise<boolean>;
10
+ export interface PluginLoadResult {
11
+ registry: ToolRegistry;
12
+ loaded: string[];
13
+ failed: string[];
14
+ }
15
+
16
+ export type PluginImporter = (specifier: string) => Promise<{ default?: unknown }>;
17
+
18
+ export class PluginLoader {
19
+ private static readonly defaultImporter: PluginImporter = (specifier) => import(specifier);
20
+ // Bun keys its module and directory caches by real path and ignores query
21
+ // strings, so re-importing a changed plugin file returns the stale module.
22
+ // Content-hash caching plus data: URL imports make /reload deterministic.
23
+ readonly #moduleCache = new Map<string, { hash: string; module: { default?: unknown } }>();
24
+ constructor(
25
+ private readonly builtins: ToolDefinition[],
26
+ private readonly events: EventBus,
27
+ private readonly auto: boolean,
28
+ private readonly trust?: ProjectTrust,
29
+ private readonly importer: PluginImporter = PluginLoader.defaultImporter,
30
+ ) {}
31
+ async load(
32
+ directories: Array<{ path: string; project: boolean }>,
33
+ baseRegistry?: ToolRegistry,
34
+ ): Promise<PluginLoadResult> {
35
+ const registry = baseRegistry?.clone() ?? new ToolRegistry(this.builtins);
36
+ const loaded: string[] = [];
37
+ const failed: string[] = [];
38
+ for (const target of directories) {
39
+ try {
40
+ const real = await realpath(target.path);
41
+ const targetStat = await stat(real);
42
+ const trustPath = targetStat.isDirectory() ? real : path.dirname(real);
43
+ if (target.project && !this.auto) {
44
+ const trusted = this.trust ? await this.trust(trustPath) : false;
45
+ if (!trusted) {
46
+ await this.events.emit("plugin.unavailable", {
47
+ path: trustPath,
48
+ reason: "not trusted",
49
+ });
50
+ continue;
51
+ }
52
+ }
53
+ const files = targetStat.isDirectory()
54
+ ? (await readdir(real))
55
+ .filter((entry) => entry.endsWith(".ts"))
56
+ .sort()
57
+ .map((entry) => path.join(real, entry))
58
+ : real.endsWith(".ts")
59
+ ? [real]
60
+ : [];
61
+ for (const file of files) {
62
+ try {
63
+ const module = await this.importPlugin(file);
64
+ if (!isToolDefinition(module.default))
65
+ throw new Error("default export is not a ToolDefinition");
66
+ registry.register(module.default, { kind: "local", path: file });
67
+ loaded.push(module.default.name);
68
+ await this.events.emit("plugin.loaded", { path: file, name: module.default.name });
69
+ } catch (error) {
70
+ failed.push(file);
71
+ await this.events.emit("plugin.failed", {
72
+ path: file,
73
+ message: error instanceof Error ? error.message : String(error),
74
+ });
75
+ }
76
+ }
77
+ } catch (error) {
78
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
79
+ failed.push(target.path);
80
+ await this.events.emit("plugin.failed", {
81
+ path: target.path,
82
+ message: error instanceof Error ? error.message : String(error),
83
+ });
84
+ }
85
+ }
86
+ }
87
+ return { registry, loaded, failed };
88
+ }
89
+
90
+ private async importPlugin(file: string): Promise<{ default?: unknown }> {
91
+ if (this.importer !== PluginLoader.defaultImporter) {
92
+ // Test seam: injected importers receive a file URL with an mtime marker.
93
+ const mtime = (await stat(file)).mtimeMs;
94
+ return this.importer(`${pathToFileURL(file).href}?mtime=${mtime}`);
95
+ }
96
+ const source = await readFile(file, "utf8");
97
+ const hash = createHash("sha256").update(source).digest("hex");
98
+ const cached = this.#moduleCache.get(file);
99
+ if (cached?.hash === hash) return cached.module;
100
+ // Plugins must be single-file and self-contained: relative imports cannot
101
+ // resolve from a data: URL, while bare package imports resolve from the
102
+ // current working directory as usual.
103
+ const stamped = `// coden-source: ${pathToFileURL(file).href}\n// coden-load: ${randomUUID()}\n${source}\n//# sourceURL=${pathToFileURL(file).href}\n`;
104
+ const module = (await this.importer(
105
+ `data:text/typescript;base64,${Buffer.from(stamped).toString("base64")}`,
106
+ )) as { default?: unknown };
107
+ this.#moduleCache.set(file, { hash, module });
108
+ return module;
109
+ }
110
+ }
111
+
112
+ function isToolDefinition(value: unknown): value is ToolDefinition {
113
+ if (!value || typeof value !== "object") return false;
114
+ const candidate = value as Partial<ToolDefinition>;
115
+ return (
116
+ typeof candidate.name === "string" &&
117
+ typeof candidate.description === "string" &&
118
+ (candidate.risk === "read" || candidate.risk === "modify" || candidate.risk === "dangerous") &&
119
+ !!candidate.inputSchema &&
120
+ typeof candidate.execute === "function"
121
+ );
122
+ }
@@ -0,0 +1,97 @@
1
+ import Ajv from "ajv";
2
+ import { CodeNError, type ToolDefinition } from "../core/types.js";
3
+
4
+ const ajv = new Ajv({ allErrors: true, strict: false, useDefaults: true });
5
+
6
+ export type ToolSource =
7
+ | { kind: "builtin" }
8
+ | { kind: "local"; path?: string }
9
+ | { kind: "npm"; pluginName: string; pluginVersion: string; path?: string };
10
+
11
+ export interface RegisteredTool {
12
+ definition: ToolDefinition;
13
+ source: ToolSource;
14
+ }
15
+
16
+ export class ToolRegistry {
17
+ #tools = new Map<string, RegisteredTool>();
18
+
19
+ constructor(tools: ToolDefinition[] = []) {
20
+ for (const tool of tools) this.register(tool, { kind: "builtin" });
21
+ }
22
+
23
+ register(tool: ToolDefinition, source: ToolSource = { kind: "local" }): void {
24
+ if (!/^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/.test(tool.name))
25
+ throw new CodeNError("plugin", "tool.invalid_name", `Invalid tool name: ${tool.name}`);
26
+ const existing = this.#tools.get(tool.name);
27
+ if (existing)
28
+ throw new CodeNError(
29
+ "plugin",
30
+ "tool.duplicate",
31
+ `Duplicate tool: ${tool.name} from ${formatSource(existing.source)} and ${formatSource(source)}`,
32
+ );
33
+ try {
34
+ ajv.compile(tool.inputSchema);
35
+ } catch (cause) {
36
+ throw new CodeNError(
37
+ "plugin",
38
+ "tool.invalid_schema",
39
+ `Invalid schema for ${tool.name}`,
40
+ false,
41
+ undefined,
42
+ { cause },
43
+ );
44
+ }
45
+ this.#tools.set(tool.name, { definition: tool, source });
46
+ }
47
+
48
+ get(name: string): ToolDefinition | undefined {
49
+ return this.#tools.get(name)?.definition;
50
+ }
51
+
52
+ list(): ToolDefinition[] {
53
+ return [...this.#tools.values()].map((entry) => entry.definition);
54
+ }
55
+
56
+ source(name: string): ToolSource | undefined {
57
+ return this.#tools.get(name)?.source;
58
+ }
59
+
60
+ entries(): RegisteredTool[] {
61
+ return [...this.#tools.values()].map((entry) => ({
62
+ definition: entry.definition,
63
+ source: entry.source,
64
+ }));
65
+ }
66
+
67
+ clone(): ToolRegistry {
68
+ const registry = new ToolRegistry();
69
+ registry.#tools = new Map(this.#tools);
70
+ return registry;
71
+ }
72
+
73
+ replaceWith(candidate: ToolRegistry): void {
74
+ this.#tools = new Map(candidate.#tools);
75
+ }
76
+
77
+ validate(name: string, input: unknown): { valid: boolean; errors?: string } {
78
+ const tool = this.get(name);
79
+ if (!tool) return { valid: false, errors: `Unknown tool: ${name}` };
80
+ const validate = ajv.compile(tool.inputSchema);
81
+ const valid = validate(input);
82
+ return valid ? { valid: true } : { valid: false, errors: ajv.errorsText(validate.errors) };
83
+ }
84
+ }
85
+
86
+ function formatSource(source: ToolSource): string {
87
+ switch (source.kind) {
88
+ case "builtin":
89
+ return "builtin";
90
+ case "local":
91
+ return source.path ? `local:${source.path}` : "local";
92
+ case "npm":
93
+ return source.path
94
+ ? `${source.pluginName}@${source.pluginVersion}:${source.path}`
95
+ : `${source.pluginName}@${source.pluginVersion}`;
96
+ }
97
+ }