jeo-code 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 (93) hide show
  1. package/README.md +342 -0
  2. package/package.json +57 -0
  3. package/scripts/install.sh +322 -0
  4. package/scripts/uninstall.sh +30 -0
  5. package/src/agent/compaction.ts +75 -0
  6. package/src/agent/config-schema.ts +87 -0
  7. package/src/agent/context-files.ts +51 -0
  8. package/src/agent/engine.ts +208 -0
  9. package/src/agent/json.ts +87 -0
  10. package/src/agent/loop.ts +22 -0
  11. package/src/agent/session.ts +198 -0
  12. package/src/agent/state.ts +199 -0
  13. package/src/agent/subagents.ts +149 -0
  14. package/src/agent/tools.ts +355 -0
  15. package/src/ai/index.ts +11 -0
  16. package/src/ai/model-catalog-compat.ts +119 -0
  17. package/src/ai/model-catalog.ts +97 -0
  18. package/src/ai/model-discovery.ts +148 -0
  19. package/src/ai/model-enrich.ts +75 -0
  20. package/src/ai/model-manager.ts +178 -0
  21. package/src/ai/model-picker.ts +73 -0
  22. package/src/ai/model-registry.ts +83 -0
  23. package/src/ai/provider-status.ts +77 -0
  24. package/src/ai/providers/anthropic.ts +87 -0
  25. package/src/ai/providers/errors.ts +47 -0
  26. package/src/ai/providers/gemini.ts +77 -0
  27. package/src/ai/providers/ollama.ts +54 -0
  28. package/src/ai/providers/openai.ts +67 -0
  29. package/src/ai/sse.ts +46 -0
  30. package/src/ai/types.ts +37 -0
  31. package/src/auth/callback-server.ts +195 -0
  32. package/src/auth/flows/anthropic.ts +114 -0
  33. package/src/auth/flows/google.ts +120 -0
  34. package/src/auth/flows/index.ts +50 -0
  35. package/src/auth/flows/openai.ts +130 -0
  36. package/src/auth/index.ts +23 -0
  37. package/src/auth/oauth.ts +80 -0
  38. package/src/auth/pkce.ts +24 -0
  39. package/src/auth/refresh.ts +60 -0
  40. package/src/auth/storage.ts +113 -0
  41. package/src/auth/types.ts +26 -0
  42. package/src/cli/index.ts +1 -0
  43. package/src/cli/runner.ts +245 -0
  44. package/src/cli.ts +17 -0
  45. package/src/commands/approve.ts +63 -0
  46. package/src/commands/auth.ts +144 -0
  47. package/src/commands/chat.ts +37 -0
  48. package/src/commands/deep-interview.ts +239 -0
  49. package/src/commands/doctor.ts +250 -0
  50. package/src/commands/evolve.ts +191 -0
  51. package/src/commands/launch.ts +745 -0
  52. package/src/commands/mcp.ts +18 -0
  53. package/src/commands/models.ts +104 -0
  54. package/src/commands/ralplan.ts +86 -0
  55. package/src/commands/resume.ts +6 -0
  56. package/src/commands/setup-helpers.ts +93 -0
  57. package/src/commands/setup.ts +190 -0
  58. package/src/commands/skills.ts +38 -0
  59. package/src/commands/team.ts +337 -0
  60. package/src/commands/ultragoal.ts +102 -0
  61. package/src/index.ts +31 -0
  62. package/src/mcp/index.ts +3 -0
  63. package/src/mcp/protocol.ts +45 -0
  64. package/src/mcp/server.ts +97 -0
  65. package/src/mcp/tools.ts +156 -0
  66. package/src/skills/catalog.ts +61 -0
  67. package/src/tui/app.ts +297 -0
  68. package/src/tui/components/ascii-art.ts +340 -0
  69. package/src/tui/components/autocomplete.ts +165 -0
  70. package/src/tui/components/capability.ts +29 -0
  71. package/src/tui/components/code-view.ts +146 -0
  72. package/src/tui/components/color.ts +172 -0
  73. package/src/tui/components/config-panel.ts +193 -0
  74. package/src/tui/components/evolution.ts +305 -0
  75. package/src/tui/components/footer.ts +95 -0
  76. package/src/tui/components/forge.ts +167 -0
  77. package/src/tui/components/index.ts +7 -0
  78. package/src/tui/components/layout.ts +105 -0
  79. package/src/tui/components/meter.ts +61 -0
  80. package/src/tui/components/model-picker.ts +82 -0
  81. package/src/tui/components/provider-picker.ts +42 -0
  82. package/src/tui/components/select-list.ts +199 -0
  83. package/src/tui/components/slash.ts +34 -0
  84. package/src/tui/components/spinner.ts +49 -0
  85. package/src/tui/components/status.ts +45 -0
  86. package/src/tui/components/stream.ts +36 -0
  87. package/src/tui/components/themes.ts +86 -0
  88. package/src/tui/components/tool-list.ts +67 -0
  89. package/src/tui/index.ts +2 -0
  90. package/src/tui/renderer.ts +70 -0
  91. package/src/tui/terminal.ts +78 -0
  92. package/src/util/retry.ts +108 -0
  93. package/tsconfig.json +18 -0
@@ -0,0 +1,199 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ import * as os from "node:os";
4
+ import { parseConfig } from "./config-schema";
5
+
6
+ /** Persisted OAuth credential set (access + refresh + expiry) for a provider. */
7
+ export interface StoredOAuth {
8
+ access: string;
9
+ refresh?: string;
10
+ /** Epoch ms after which the access token is considered expired (skew-adjusted at mint time). */
11
+ expires?: number;
12
+ accountId?: string;
13
+ email?: string;
14
+ projectId?: string;
15
+ }
16
+
17
+ export interface Config {
18
+ providers: {
19
+ anthropic?: string;
20
+ openai?: string;
21
+ gemini?: string;
22
+ };
23
+ /**
24
+ * OAuth credentials (take precedence over API keys for the same provider).
25
+ * A bare string is a legacy/manually-pasted bearer with no refresh metadata;
26
+ * a {@link StoredOAuth} object carries refresh token + expiry for auto-refresh.
27
+ */
28
+ oauth?: {
29
+ anthropic?: string | StoredOAuth;
30
+ openai?: string | StoredOAuth;
31
+ gemini?: string | StoredOAuth;
32
+ };
33
+ /** Base URL for the local Ollama server (keyless). */
34
+ ollamaBaseUrl?: string;
35
+ /** Base URL override for OpenAI-compatible providers (LM Studio, vLLM, llama-cpp-server, ...). */
36
+ openaiBaseUrl?: string;
37
+ defaultModel: string;
38
+ thinkingLevel?: "minimal" | "low" | "medium" | "high" | "xhigh";
39
+ /** Friendly model aliases, e.g. { fast: "ollama/qwen2.5:0.5b" }. Override built-ins. */
40
+ modelAliases?: { [alias: string]: string };
41
+ /**
42
+ * Provider retry budgets (gjc parity). `requestMaxRetries` is the number of
43
+ * retries (excluding the initial request) for a provider request; `maxDelayMs`
44
+ * caps exponential backoff. `maxRetries`/`streamMaxRetries` are accepted for
45
+ * gjc-config compatibility.
46
+ */
47
+ retry?: {
48
+ requestMaxRetries?: number;
49
+ streamMaxRetries?: number;
50
+ maxRetries?: number;
51
+ maxDelayMs?: number;
52
+ };
53
+ /**
54
+ * Per-subagent-role overrides (gjc role-agent parity). Keyed by role id
55
+ * (executor / planner / architect / critic); each may pin a model and/or a
56
+ * tool-loop step budget.
57
+ */
58
+ subagents?: { [roleId: string]: { model?: string; maxSteps?: number } };
59
+ /**
60
+ * Model role tiers (gjc `--smol`/`--slow`/`--plan` parity). Each falls back to
61
+ * `defaultModel`. Env `JOC_SMOL_MODEL`/`JOC_SLOW_MODEL`/`JOC_PLAN_MODEL` fill gaps.
62
+ */
63
+ roles?: { smol?: string; slow?: string; plan?: string };
64
+ }
65
+
66
+ export interface WorkflowState {
67
+ active: boolean;
68
+ current_phase: string;
69
+ skill: "deep-interview" | "ralplan" | "team" | "ultragoal";
70
+ interview_id?: string;
71
+ slug?: string;
72
+ initial_idea?: string;
73
+ current_ambiguity?: number;
74
+ threshold?: number;
75
+ seed_path?: string;
76
+ plan_path?: string;
77
+ completed_tasks?: string[];
78
+ pending_tasks?: string[];
79
+ approved?: boolean;
80
+ }
81
+
82
+ /**
83
+ * Resolve the global config directory at call time (not import time) so that a
84
+ * `JOC_CONFIG_DIR` override or a runtime `HOME` change is always honored.
85
+ * `JOC_CONFIG_DIR` takes precedence; otherwise `~/.joc`.
86
+ */
87
+ function globalConfigDir(): string {
88
+ return process.env.JOC_CONFIG_DIR || path.join(os.homedir(), ".joc");
89
+ }
90
+ function globalConfigPath(): string {
91
+ return path.join(globalConfigDir(), "config.json");
92
+ }
93
+
94
+ function envOAuth(): NonNullable<Config["oauth"]> {
95
+ return {
96
+ anthropic: process.env.ANTHROPIC_OAUTH_TOKEN || process.env.CLAUDE_CODE_OAUTH_TOKEN,
97
+ openai: process.env.OPENAI_OAUTH_TOKEN,
98
+ gemini: process.env.GEMINI_OAUTH_TOKEN,
99
+ };
100
+ }
101
+
102
+ /** Merge env-provided OAuth tokens / Ollama base over a config (env fills gaps only). */
103
+ function withEnvOverlay(cfg: Config): Config {
104
+ const envTok = envOAuth();
105
+ const oauth = { ...envTok, ...(cfg.oauth ?? {}) };
106
+ return {
107
+ ...cfg,
108
+ oauth,
109
+ defaultModel: process.env.JOC_DEFAULT_MODEL || cfg.defaultModel,
110
+ ollamaBaseUrl: cfg.ollamaBaseUrl || process.env.OLLAMA_HOST || "http://localhost:11434",
111
+ openaiBaseUrl: cfg.openaiBaseUrl || process.env.OPENAI_BASE_URL,
112
+ roles: {
113
+ smol: cfg.roles?.smol || process.env.JOC_SMOL_MODEL,
114
+ slow: cfg.roles?.slow || process.env.JOC_SLOW_MODEL,
115
+ plan: cfg.roles?.plan || process.env.JOC_PLAN_MODEL,
116
+ },
117
+ };
118
+ }
119
+
120
+ function envDefaultConfig(): Config {
121
+ return {
122
+ providers: {
123
+ anthropic: process.env.ANTHROPIC_API_KEY,
124
+ openai: process.env.OPENAI_API_KEY,
125
+ gemini: process.env.GEMINI_API_KEY,
126
+ },
127
+ defaultModel: process.env.JOC_DEFAULT_MODEL || "claude-3-5-sonnet",
128
+ thinkingLevel: "medium",
129
+ };
130
+ }
131
+
132
+ export async function readGlobalConfig(): Promise<Config> {
133
+ let data: string;
134
+ try {
135
+ data = await fs.readFile(globalConfigPath(), "utf-8");
136
+ } catch {
137
+ return withEnvOverlay(envDefaultConfig());
138
+ }
139
+
140
+ let raw: unknown;
141
+ try {
142
+ raw = JSON.parse(data);
143
+ } catch {
144
+ process.stderr.write(`[joc] ${globalConfigPath()} is not valid JSON; using environment defaults.\n`);
145
+ return withEnvOverlay(envDefaultConfig());
146
+ }
147
+
148
+ const parsed = parseConfig(raw);
149
+ if (!parsed.ok) {
150
+ process.stderr.write(`[joc] ${globalConfigPath()} is invalid (${parsed.message}); using environment defaults.\n`);
151
+ return withEnvOverlay(envDefaultConfig());
152
+ }
153
+ return withEnvOverlay(parsed.config as Config);
154
+ }
155
+
156
+ export async function saveGlobalConfig(config: Config): Promise<void> {
157
+ await fs.mkdir(globalConfigDir(), { recursive: true, mode: 0o700 });
158
+ await fs.writeFile(globalConfigPath(), JSON.stringify(config, null, 2), { encoding: "utf-8", mode: 0o600 });
159
+ await fs.chmod(globalConfigPath(), 0o600).catch(() => {}); // ensure mode even if file pre-existed
160
+ }
161
+
162
+ export function getLocalJocDir(cwd: string = process.cwd()): string {
163
+ return path.join(cwd, ".joc");
164
+ }
165
+
166
+ export async function readWorkflowState(
167
+ skill: "deep-interview" | "ralplan" | "team" | "ultragoal",
168
+ cwd: string = process.cwd()
169
+ ): Promise<WorkflowState | null> {
170
+ const statePath = path.join(getLocalJocDir(cwd), "state", `${skill}-state.json`);
171
+ try {
172
+ const data = await fs.readFile(statePath, "utf-8");
173
+ return JSON.parse(data) as WorkflowState;
174
+ } catch {
175
+ return null;
176
+ }
177
+ }
178
+
179
+ export async function writeWorkflowState(
180
+ skill: "deep-interview" | "ralplan" | "team" | "ultragoal",
181
+ state: WorkflowState,
182
+ cwd: string = process.cwd()
183
+ ): Promise<string> {
184
+ const stateDir = path.join(getLocalJocDir(cwd), "state");
185
+ await fs.mkdir(stateDir, { recursive: true });
186
+ const statePath = path.join(stateDir, `${skill}-state.json`);
187
+ await fs.writeFile(statePath, JSON.stringify(state, null, 2), "utf-8");
188
+ return statePath;
189
+ }
190
+
191
+ export async function clearWorkflowState(
192
+ skill: "deep-interview" | "ralplan" | "team" | "ultragoal",
193
+ cwd: string = process.cwd()
194
+ ): Promise<void> {
195
+ const statePath = path.join(getLocalJocDir(cwd), "state", `${skill}-state.json`);
196
+ try {
197
+ await fs.unlink(statePath);
198
+ } catch {}
199
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Subagent role registry (gjc role-agent parity: executor / planner / architect /
3
+ * critic). A "subagent" is the executor tool-loop driven with a role-specific
4
+ * system prompt, model, step budget, and toolset. The registry is pure data so
5
+ * it can be listed in the TUI (`/agents`) and consumed by `joc team` without
6
+ * importing any provider or I/O code.
7
+ *
8
+ * Read-only roles (planner/architect/critic) get a mutation-free toolset so a
9
+ * review/plan lane physically cannot edit the repo, mirroring gjc's read-only
10
+ * role agents.
11
+ */
12
+ import { DEFAULT_TOOLS, executorSystemPrompt, type ToolHandler } from "./engine";
13
+ import type { Config } from "./state";
14
+
15
+ export interface SubagentRole {
16
+ /** Stable id used in config + `/agents <id>`. */
17
+ id: string;
18
+ /** Human title shown in listings. */
19
+ title: string;
20
+ /** One-line purpose. */
21
+ description: string;
22
+ /** Read-only roles must not mutate the repo (no write/edit tools). */
23
+ readOnly: boolean;
24
+ /** Default tool-loop step budget for this role. */
25
+ defaultMaxSteps: number;
26
+ }
27
+
28
+ /** The four bundled subagent roles. `executor` is the only mutating role. */
29
+ export const SUBAGENT_ROLES: readonly SubagentRole[] = [
30
+ {
31
+ id: "executor",
32
+ title: "Executor",
33
+ description: "Bounded implementation, refactors, fixes, and verification-ready edits.",
34
+ readOnly: false,
35
+ defaultMaxSteps: 15,
36
+ },
37
+ {
38
+ id: "planner",
39
+ title: "Planner",
40
+ description: "Read-only sequencing, acceptance criteria, risks, and handoff shape.",
41
+ readOnly: true,
42
+ defaultMaxSteps: 10,
43
+ },
44
+ {
45
+ id: "architect",
46
+ title: "Architect",
47
+ description: "Read-only architecture and code review with severity-rated findings.",
48
+ readOnly: true,
49
+ defaultMaxSteps: 10,
50
+ },
51
+ {
52
+ id: "critic",
53
+ title: "Critic",
54
+ description: "Read-only plan critic; approves only actionable, verifiable plans.",
55
+ readOnly: true,
56
+ defaultMaxSteps: 8,
57
+ },
58
+ ];
59
+
60
+ const DEFAULT_ROLE_ID = "executor";
61
+
62
+ /** Normalize loosely-typed role input (case-insensitive, trimmed). */
63
+ export function normalizeRoleId(input: string | undefined | null): string {
64
+ return (input ?? "").trim().toLowerCase();
65
+ }
66
+
67
+ /** Look up a role by id (case-insensitive). Returns undefined when unknown. */
68
+ export function getSubagentRole(id: string | undefined | null): SubagentRole | undefined {
69
+ const want = normalizeRoleId(id);
70
+ return SUBAGENT_ROLES.find(r => r.id === want);
71
+ }
72
+
73
+ /** The default role (`executor`) used when none is specified. */
74
+ export function defaultSubagentRole(): SubagentRole {
75
+ // Non-null: DEFAULT_ROLE_ID is guaranteed present in SUBAGENT_ROLES.
76
+ return getSubagentRole(DEFAULT_ROLE_ID)!;
77
+ }
78
+
79
+ export type SubagentConfig = NonNullable<Config["subagents"]>;
80
+
81
+ /** Per-role model override → falls back to the global default model. */
82
+ export function resolveSubagentModel(roleId: string, config: Pick<Config, "defaultModel" | "subagents">): string {
83
+ const entry = config.subagents?.[normalizeRoleId(roleId)];
84
+ return entry?.model ?? config.defaultModel;
85
+ }
86
+
87
+ /** Per-role step budget → config override, else the role default, else 15. */
88
+ export function resolveSubagentMaxSteps(roleId: string, config: Pick<Config, "subagents">): number {
89
+ const entry = config.subagents?.[normalizeRoleId(roleId)];
90
+ if (typeof entry?.maxSteps === "number" && entry.maxSteps > 0) return entry.maxSteps;
91
+ return getSubagentRole(roleId)?.defaultMaxSteps ?? 15;
92
+ }
93
+
94
+ /** Build a role-specific executor system prompt; read-only roles get a no-mutation directive. */
95
+ export function subagentSystemPrompt(role: SubagentRole): string {
96
+ const base = executorSystemPrompt(`${role.title} subagent — ${role.description}`);
97
+ if (!role.readOnly) return base;
98
+ return (
99
+ base +
100
+ `\n\nYou are a READ-ONLY ${role.title}. Do not create or modify files; ` +
101
+ `use read / find / search (and read-only bash) only, then report your findings via done.`
102
+ );
103
+ }
104
+
105
+ /** Toolset for a role: read-only roles drop the mutating tools (write/edit). */
106
+ export function subagentToolset(role: SubagentRole): Record<string, ToolHandler> {
107
+ if (!role.readOnly) return DEFAULT_TOOLS;
108
+ const ro: Record<string, ToolHandler> = {};
109
+ for (const [name, handler] of Object.entries(DEFAULT_TOOLS)) {
110
+ if (name === "write" || name === "edit") continue;
111
+ ro[name] = handler;
112
+ }
113
+ return ro;
114
+ }
115
+
116
+ /** All role ids (for `/agents` autocomplete + validation). */
117
+ export function subagentRoleIds(): string[] {
118
+ return SUBAGENT_ROLES.map(r => r.id);
119
+ }
120
+
121
+ /** Parse a `/agents <role> maxSteps <n>` value → positive int, else undefined. */
122
+ export function parseMaxSteps(input: string | undefined): number | undefined {
123
+ const n = parseInt((input ?? "").trim(), 10);
124
+ return Number.isFinite(n) && n > 0 ? n : undefined;
125
+ }
126
+
127
+ /**
128
+ * Return a new `subagents` map with a role's settings patched (model and/or
129
+ * maxSteps). Pure — does not mutate `config`. Unknown roles are rejected by the
130
+ * caller via `getSubagentRole`; this helper trusts the id it is given.
131
+ */
132
+ export function withSubagentSetting(
133
+ config: Pick<Config, "subagents">,
134
+ roleId: string,
135
+ patch: { model?: string; maxSteps?: number },
136
+ ): SubagentConfig {
137
+ const id = normalizeRoleId(roleId);
138
+ const subs: SubagentConfig = { ...(config.subagents ?? {}) };
139
+ subs[id] = { ...subs[id], ...patch };
140
+ return subs;
141
+ }
142
+
143
+ /** Return a new `subagents` map with a role's override removed (reset to defaults). */
144
+ export function clearSubagentSetting(config: Pick<Config, "subagents">, roleId: string): SubagentConfig {
145
+ const id = normalizeRoleId(roleId);
146
+ const subs: SubagentConfig = { ...(config.subagents ?? {}) };
147
+ delete subs[id];
148
+ return subs;
149
+ }
@@ -0,0 +1,355 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ import { readWorkflowState } from "./state";
4
+
5
+ export interface ToolResult {
6
+ success: boolean;
7
+ output: string;
8
+ error?: string;
9
+ }
10
+
11
+ /**
12
+ * Directories that pollute `find`/`search` results and waste time: VCS, build
13
+ * artifacts, dependency trees, and joc's own runtime dir. gjc's native search
14
+ * respects ignore files; this is the pure-TS equivalent.
15
+ */
16
+ export const IGNORED_DIRS = [
17
+ "node_modules",
18
+ ".git",
19
+ "dist",
20
+ "build",
21
+ "coverage",
22
+ ".next",
23
+ ".joc",
24
+ "vendor",
25
+ ".cache",
26
+ ];
27
+
28
+ /**
29
+ * Validates if codebase mutation tools are blocked due to an active Socratic interview.
30
+ * Mutation is blocked only if deep-interview is active, not completed, and the file
31
+ * is NOT under the `.joc/` directory (planning/spec files are allowed).
32
+ */
33
+ export async function assertMutationAllowed(
34
+ filePath: string,
35
+ cwd: string = process.cwd()
36
+ ): Promise<void> {
37
+ const deepInterviewState = await readWorkflowState("deep-interview", cwd);
38
+ if (deepInterviewState && deepInterviewState.active && deepInterviewState.current_phase !== "complete") {
39
+ // Check if the target is NOT inside the local .joc folder. Use a path-boundary
40
+ // check (not bare startsWith) so siblings like ".joc-backup" aren't mistaken for ".joc/".
41
+ const absPath = path.resolve(cwd, filePath);
42
+ const jocDir = path.resolve(cwd, ".joc");
43
+ const insideJoc = absPath === jocDir || absPath.startsWith(jocDir + path.sep);
44
+ if (!insideJoc) {
45
+ throw new Error(
46
+ `[MutationGuard Blocked] Code mutation is strictly blocked during an active Socratic interview.\n` +
47
+ `Current Ambiguity Score: ${((deepInterviewState.current_ambiguity ?? 1) * 100).toFixed(0)}% (must be <= 20% to unlock).\n` +
48
+ `Only spec/planning writes under '.joc/' are permitted. Finish requirements with 'joc deep-interview' first.`
49
+ );
50
+ }
51
+ }
52
+ }
53
+
54
+ export async function assertBashAllowed(
55
+ cwd: string = process.cwd()
56
+ ): Promise<void> {
57
+ const deepInterviewState = await readWorkflowState("deep-interview", cwd);
58
+ if (deepInterviewState && deepInterviewState.active && deepInterviewState.current_phase !== "complete") {
59
+ throw new Error(
60
+ "[MutationGuard] bash is disabled during an active Socratic interview (ambiguity must reach <=20% first). Finish 'joc deep-interview'."
61
+ );
62
+ }
63
+ }
64
+
65
+ export async function readTool(
66
+ filePath: string,
67
+ lineRange?: string,
68
+ cwd: string = process.cwd()
69
+ ): Promise<ToolResult> {
70
+ try {
71
+ const absPath = path.resolve(cwd, filePath);
72
+ const content = await fs.readFile(absPath, "utf-8");
73
+ const lines = content.split("\n");
74
+
75
+ if (lineRange) {
76
+ // Accept "start-end", open-ended "start-", or a single "start".
77
+ const match = lineRange.match(/^(\d+)(?:-(\d+)?)?$/);
78
+ if (match) {
79
+ const start = Math.max(1, parseInt(match[1]));
80
+ const hasRange = lineRange.includes("-");
81
+ const end = match[2]
82
+ ? Math.min(lines.length, parseInt(match[2]))
83
+ : hasRange
84
+ ? lines.length // "start-" → to EOF
85
+ : Math.min(lines.length, start); // single line
86
+ if (end < start) {
87
+ return { success: false, output: "", error: `Invalid lineRange '${lineRange}': end < start (file has ${lines.length} lines)` };
88
+ }
89
+ const sliced = lines.slice(start - 1, end).map((l, i) => `${start + i}|${l}`).join("\n");
90
+ return { success: true, output: sliced };
91
+ }
92
+ return { success: false, output: "", error: `Invalid lineRange '${lineRange}'. Use "start-end", "start-", or "start".` };
93
+ }
94
+
95
+ const MAX_LINES = 500;
96
+ const annotated = lines.slice(0, MAX_LINES).map((l, i) => `${i + 1}|${l}`).join("\n");
97
+ if (lines.length > MAX_LINES) {
98
+ const notice = `\n…(showing lines 1-${MAX_LINES} of ${lines.length}; pass lineRange "${MAX_LINES + 1}-" to read the rest)`;
99
+ return { success: true, output: annotated + notice };
100
+ }
101
+ return { success: true, output: annotated };
102
+ } catch (err: any) {
103
+ return { success: false, output: "", error: err.message };
104
+ }
105
+ }
106
+
107
+ export async function writeTool(
108
+ filePath: string,
109
+ content: string,
110
+ cwd: string = process.cwd()
111
+ ): Promise<ToolResult> {
112
+ try {
113
+ await assertMutationAllowed(filePath, cwd);
114
+ const absPath = path.resolve(cwd, filePath);
115
+ await fs.mkdir(path.dirname(absPath), { recursive: true });
116
+ await fs.writeFile(absPath, content, "utf-8");
117
+ return { success: true, output: `Successfully wrote ${content.length} characters to ${filePath}` };
118
+ } catch (err: any) {
119
+ return { success: false, output: "", error: err.message };
120
+ }
121
+ }
122
+
123
+ export async function editTool(
124
+ filePath: string,
125
+ editBlock: string,
126
+ cwd: string = process.cwd()
127
+ ): Promise<ToolResult> {
128
+ try {
129
+ await assertMutationAllowed(filePath, cwd);
130
+ const absPath = path.resolve(cwd, filePath);
131
+ let content = await fs.readFile(absPath, "utf-8");
132
+
133
+ // Line-anchored edit parser. Modes (payload follows the directive's newline):
134
+ // ≔A..B replace lines A..B ≔A replace line A
135
+ // ≔A+ insert AFTER line A (A=0 prepends)
136
+ // ≔$ append to end of file
137
+ // Falls back to <<<<<<< SEARCH / ======= / >>>>>>> substring replacement.
138
+ const lines = content.split("\n");
139
+
140
+ let updated = false;
141
+ if (editBlock.startsWith("≔")) {
142
+ const appendMatch = editBlock.match(/^≔\$\n?([\s\S]*)$/);
143
+ const insertMatch = editBlock.match(/^≔(\d+)\+\n?([\s\S]*)$/);
144
+ const replaceMatch = editBlock.match(/^≔(\d+)(?:\.\.(\d+))?\n([\s\S]*)$/);
145
+ if (appendMatch) {
146
+ const payload = appendMatch[1];
147
+ content = content === "" || content.endsWith("\n") ? content + payload : content + "\n" + payload;
148
+ updated = true;
149
+ } else if (insertMatch) {
150
+ const at = parseInt(insertMatch[1]); // insert AFTER line `at`; 0 prepends
151
+ const payload = insertMatch[2];
152
+ if (at < 0 || at > lines.length) {
153
+ return { success: false, output: "", error: `Invalid insert position ${at}: out of bounds (file has ${lines.length} lines)` };
154
+ }
155
+ lines.splice(at, 0, payload);
156
+ content = lines.join("\n");
157
+ updated = true;
158
+ } else if (replaceMatch) {
159
+ const startLine = parseInt(replaceMatch[1]);
160
+ const endLine = replaceMatch[2] ? parseInt(replaceMatch[2]) : startLine;
161
+ const payload = replaceMatch[3];
162
+ if (startLine < 1 || endLine < startLine || endLine > lines.length) {
163
+ return {
164
+ success: false,
165
+ output: "",
166
+ error: `Invalid edit range ${startLine}..${endLine}: out of bounds or reversed (file has ${lines.length} lines)`,
167
+ };
168
+ }
169
+ lines.splice(startLine - 1, endLine - startLine + 1, payload);
170
+ content = lines.join("\n");
171
+ updated = true;
172
+ }
173
+ }
174
+
175
+ if (!updated) {
176
+ // Direct substring replacement fallback
177
+ const searchMatch = editBlock.split("<<<<<<< SEARCH");
178
+ if (searchMatch.length > 1) {
179
+ const parts = searchMatch[1].split("=======");
180
+ if (parts.length > 1) {
181
+ let searchVal = parts[0];
182
+ if (searchVal.startsWith("\r\n")) {
183
+ searchVal = searchVal.slice(2);
184
+ } else if (searchVal.startsWith("\n")) {
185
+ searchVal = searchVal.slice(1);
186
+ }
187
+ if (searchVal.endsWith("\r\n")) {
188
+ searchVal = searchVal.slice(0, -2);
189
+ } else if (searchVal.endsWith("\n")) {
190
+ searchVal = searchVal.slice(0, -1);
191
+ }
192
+
193
+ if (searchVal === "") {
194
+ return {
195
+ success: false,
196
+ output: "",
197
+ error: "Failed to apply edit: Search block is empty.",
198
+ };
199
+ }
200
+
201
+ const replaceParts = parts[1].split(">>>>>>>");
202
+ if (replaceParts.length > 0) {
203
+ let replaceVal = replaceParts[0];
204
+ if (replaceVal.startsWith("\r\n")) {
205
+ replaceVal = replaceVal.slice(2);
206
+ } else if (replaceVal.startsWith("\n")) {
207
+ replaceVal = replaceVal.slice(1);
208
+ }
209
+ if (replaceVal.endsWith("\r\n")) {
210
+ replaceVal = replaceVal.slice(0, -2);
211
+ } else if (replaceVal.endsWith("\n")) {
212
+ replaceVal = replaceVal.slice(0, -1);
213
+ }
214
+
215
+ if (content.includes(searchVal)) {
216
+ content = content.replace(searchVal, replaceVal);
217
+ updated = true;
218
+ } else {
219
+ return {
220
+ success: false,
221
+ output: "",
222
+ error: "Failed to apply edit: Search block not found in file.",
223
+ };
224
+ }
225
+ }
226
+ }
227
+ }
228
+ }
229
+
230
+ if (!updated) {
231
+ return {
232
+ success: false,
233
+ output: "",
234
+ error: "Failed to apply edit: Invalid edit block format. Use line range replacement: ≔[line]..[line] format.",
235
+ };
236
+ }
237
+
238
+ await fs.writeFile(absPath, content, "utf-8");
239
+ return { success: true, output: `Successfully updated ${filePath}` };
240
+ } catch (err: any) {
241
+ return { success: false, output: "", error: err.message };
242
+ }
243
+ }
244
+
245
+ export async function bashTool(
246
+ command: string,
247
+ cwd: string = process.cwd(),
248
+ timeoutMs: number = 120_000
249
+ ): Promise<ToolResult> {
250
+ try {
251
+ await assertBashAllowed(cwd);
252
+ // Run the command using Bun's native spawn
253
+ const proc = Bun.spawn(["bash", "-c", command], {
254
+ cwd,
255
+ stdout: "pipe",
256
+ stderr: "pipe",
257
+ });
258
+
259
+ let timedOut = false;
260
+ const TIMEOUT_MS = timeoutMs;
261
+ let killTimer: ReturnType<typeof setTimeout> | undefined;
262
+ const timer = setTimeout(() => {
263
+ timedOut = true;
264
+ // Graceful first (SIGTERM), then force-kill (SIGKILL) if it ignores it.
265
+ try { proc.kill(); } catch {}
266
+ killTimer = setTimeout(() => { try { proc.kill(9); } catch {} }, 3_000);
267
+ }, TIMEOUT_MS);
268
+
269
+ await proc.exited;
270
+ clearTimeout(timer);
271
+ if (killTimer) clearTimeout(killTimer);
272
+
273
+ const stdout = await new Response(proc.stdout).text();
274
+ const stderr = await new Response(proc.stderr).text();
275
+
276
+ let output = [stdout, stderr].filter(Boolean).join("\n");
277
+ const MAX_OUTPUT = 100_000;
278
+ if (output.length > MAX_OUTPUT) {
279
+ output = output.slice(0, MAX_OUTPUT) + "\n…(output truncated at 100000 chars)";
280
+ }
281
+
282
+ if (timedOut) {
283
+ return {
284
+ success: false,
285
+ output,
286
+ error: `Command timed out after ${Math.round(TIMEOUT_MS / 1000)}s and was killed`,
287
+ };
288
+ }
289
+
290
+ return {
291
+ success: proc.exitCode === 0,
292
+ output: output || "(no output)",
293
+ error: proc.exitCode !== 0 ? `Exit code ${proc.exitCode}` : undefined,
294
+ };
295
+ } catch (err: any) {
296
+ return { success: false, output: "", error: err.message };
297
+ }
298
+ }
299
+
300
+ export async function findTool(
301
+ globPattern: string,
302
+ cwd: string = process.cwd()
303
+ ): Promise<ToolResult> {
304
+ try {
305
+ const pruneGroup: string[] = [];
306
+ for (let i = 0; i < IGNORED_DIRS.length; i++) {
307
+ if (i > 0) pruneGroup.push("-o");
308
+ pruneGroup.push("-name", IGNORED_DIRS[i]);
309
+ }
310
+ const proc = Bun.spawn(
311
+ ["find", ".", "-type", "d", "(", ...pruneGroup, ")", "-prune", "-o", "-name", globPattern, "-print"],
312
+ { cwd, stdout: "pipe", stderr: "pipe" },
313
+ );
314
+ await proc.exited;
315
+ const stdout = await new Response(proc.stdout).text();
316
+ const files = stdout.split("\n").filter(Boolean);
317
+ let output = files.length > 0 ? files.join("\n") : "No matching files found.";
318
+ const MAX_OUTPUT = 100_000;
319
+ if (output.length > MAX_OUTPUT) {
320
+ output = output.slice(0, MAX_OUTPUT) + "\n…(output truncated at 100000 chars)";
321
+ }
322
+ return { success: true, output };
323
+ } catch (err: any) {
324
+ return { success: false, output: "", error: err.message };
325
+ }
326
+ }
327
+
328
+ export async function searchTool(
329
+ pattern: string,
330
+ globPattern: string = "*",
331
+ cwd: string = process.cwd()
332
+ ): Promise<ToolResult> {
333
+ try {
334
+ const excludes = IGNORED_DIRS.map(d => `--exclude-dir=${d}`);
335
+ const proc = Bun.spawn(
336
+ ["grep", "-rnI", "--include", globPattern, ...excludes, "--", pattern, "."],
337
+ { cwd, stdout: "pipe", stderr: "pipe" },
338
+ );
339
+ await proc.exited;
340
+ const stdout = await new Response(proc.stdout).text();
341
+ const stderr = await new Response(proc.stderr).text();
342
+ // grep exit codes: 0 = match, 1 = no match (not an error), >=2 = a real error.
343
+ if (proc.exitCode !== null && proc.exitCode >= 2) {
344
+ return { success: false, output: stdout, error: stderr.trim() || `grep failed (exit ${proc.exitCode})` };
345
+ }
346
+ let output = stdout || "No matches found.";
347
+ const MAX_OUTPUT = 100_000;
348
+ if (output.length > MAX_OUTPUT) {
349
+ output = output.slice(0, MAX_OUTPUT) + "\n…(output truncated at 100000 chars)";
350
+ }
351
+ return { success: true, output };
352
+ } catch (err: any) {
353
+ return { success: false, output: "", error: err.message };
354
+ }
355
+ }