@cogineai/dearharness 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 (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +141 -0
  3. package/dist/cli.js +259 -0
  4. package/dist/config.js +21 -0
  5. package/dist/daemon/server.js +225 -0
  6. package/dist/extensions/builtin/policy-instructions.js +19 -0
  7. package/dist/extensions/loader.js +58 -0
  8. package/dist/extensions/types.js +1 -0
  9. package/dist/harness/install-applicator.js +126 -0
  10. package/dist/harness/install-apply.js +156 -0
  11. package/dist/harness/install-plan.js +154 -0
  12. package/dist/harness/install-runner.js +178 -0
  13. package/dist/harness/install-verification.js +117 -0
  14. package/dist/harness/lockfile.js +83 -0
  15. package/dist/harness/manifest.js +491 -0
  16. package/dist/harness/source.js +224 -0
  17. package/dist/harness/transaction.js +77 -0
  18. package/dist/harness/workspace.js +61 -0
  19. package/dist/index.js +9 -0
  20. package/dist/instructions/builder.js +33 -0
  21. package/dist/instructions/types.js +1 -0
  22. package/dist/model/config.js +100 -0
  23. package/dist/model/http.js +128 -0
  24. package/dist/model/index.js +22 -0
  25. package/dist/model/openrouter.js +9 -0
  26. package/dist/model/providers/anthropic.js +104 -0
  27. package/dist/model/providers/ollama-discovery.js +32 -0
  28. package/dist/model/providers/ollama.js +70 -0
  29. package/dist/model/providers/openai-compatible.js +118 -0
  30. package/dist/model/providers/openai.js +4 -0
  31. package/dist/model/providers/openrouter.js +79 -0
  32. package/dist/model/registry.js +108 -0
  33. package/dist/model/types.js +1 -0
  34. package/dist/policy/engine.js +30 -0
  35. package/dist/policy/types.js +1 -0
  36. package/dist/prompt/system.js +30 -0
  37. package/dist/protocol/actions.js +88 -0
  38. package/dist/runtime/assembly.js +54 -0
  39. package/dist/runtime/events.js +1 -0
  40. package/dist/runtime/hooks.js +13 -0
  41. package/dist/runtime/runner.js +193 -0
  42. package/dist/session/store.js +198 -0
  43. package/dist/session/types.js +1 -0
  44. package/dist/skills/loader.js +51 -0
  45. package/dist/skills/types.js +1 -0
  46. package/dist/tools/bash.js +71 -0
  47. package/dist/tools/edit.js +61 -0
  48. package/dist/tools/find.js +67 -0
  49. package/dist/tools/grep.js +88 -0
  50. package/dist/tools/ls.js +37 -0
  51. package/dist/tools/path.js +35 -0
  52. package/dist/tools/read.js +40 -0
  53. package/dist/tools/registry.js +18 -0
  54. package/dist/tools/types.js +1 -0
  55. package/dist/workspace/config.js +72 -0
  56. package/package.json +52 -0
@@ -0,0 +1,54 @@
1
+ import { loadExtensions } from '../extensions/loader.js';
2
+ import { buildInstructionMessages, loadWorkspaceInstructionFiles } from '../instructions/builder.js';
3
+ import { BASE_SYSTEM_PROMPT } from '../prompt/system.js';
4
+ import { loadSkills, mergeSkillNames } from '../skills/loader.js';
5
+ import { loadWorkspaceConfig } from '../workspace/config.js';
6
+ const INSTRUCTION_LAYERS = new Set(['core', 'workspace', 'skill', 'extension']);
7
+ function validateExtensionMessages(extensionName, value) {
8
+ if (!Array.isArray(value)) {
9
+ throw new Error(`Extension ${extensionName} instruction source returned invalid value, expected array`);
10
+ }
11
+ value.forEach((message, index) => {
12
+ if (!message ||
13
+ typeof message !== 'object' ||
14
+ message.role !== 'system' ||
15
+ typeof message.content !== 'string' ||
16
+ typeof message.source !== 'string' ||
17
+ !INSTRUCTION_LAYERS.has(message.layer)) {
18
+ throw new Error(`Extension ${extensionName} instruction source returned invalid message at index ${index}`);
19
+ }
20
+ });
21
+ return value;
22
+ }
23
+ export async function createRuntimeAssembly({ cwd, session, policyMode, cliSkillNames }) {
24
+ const workspaceConfig = await loadWorkspaceConfig(cwd);
25
+ const skillNames = mergeSkillNames(workspaceConfig.defaultSkills, cliSkillNames);
26
+ const extensions = await loadExtensions(cwd, workspaceConfig.extensions);
27
+ const skills = await loadSkills(cwd, skillNames);
28
+ const workspaceInstructions = await loadWorkspaceInstructionFiles(cwd, workspaceConfig.instructionFiles);
29
+ return {
30
+ workspaceConfig,
31
+ skillNames,
32
+ extensionNames: extensions.map((extension) => extension.name),
33
+ hooks: extensions.flatMap((extension) => extension.hooks ?? []),
34
+ session,
35
+ async instructions(currentSession) {
36
+ const extensionMessages = (await Promise.all(extensions.flatMap((extension) => (extension.instructionSources ?? []).map(async (source) => {
37
+ try {
38
+ const messages = await source({ cwd, session: currentSession, policyMode });
39
+ return validateExtensionMessages(extension.name, messages);
40
+ }
41
+ catch (error) {
42
+ throw new Error(`Extension ${extension.name} instruction source failed: ${error instanceof Error ? error.message : String(error)}`);
43
+ }
44
+ })))).flat();
45
+ return buildInstructionMessages({
46
+ cwd,
47
+ basePrompt: BASE_SYSTEM_PROMPT,
48
+ workspaceInstructions,
49
+ skills: skills.map((skill) => ({ name: skill.name, prompt: skill.prompt })),
50
+ extensionMessages
51
+ });
52
+ }
53
+ };
54
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,13 @@
1
+ export async function runHooks(hooks, name, ...args) {
2
+ for (const hook of hooks) {
3
+ const fn = hook[name];
4
+ if (fn) {
5
+ try {
6
+ await fn(...args);
7
+ }
8
+ catch (error) {
9
+ console.error(`hook ${String(name)} failed:`, error);
10
+ }
11
+ }
12
+ }
13
+ }
@@ -0,0 +1,193 @@
1
+ import { DEFAULT_POLICY_MODE, MAX_LOOPS } from '../config.js';
2
+ import { createPolicyEngine } from '../policy/engine.js';
3
+ import { parseModelAction } from '../protocol/actions.js';
4
+ import { appendRecord, makeId, nowIso, saveSession } from '../session/store.js';
5
+ import { createToolRegistry } from '../tools/registry.js';
6
+ import { runHooks } from './hooks.js';
7
+ function buildChatMessages(session, instructions) {
8
+ return [
9
+ ...instructions.map(({ role, content }) => ({ role, content })),
10
+ ...session.records.map((record) => record.kind === 'tool'
11
+ ? { role: 'user', content: record.content }
12
+ : { role: record.role, content: record.content })
13
+ ];
14
+ }
15
+ export function createRunner({ model, registry = createToolRegistry(), hooks = [], policy = createPolicyEngine({ mode: DEFAULT_POLICY_MODE }), instructions = async () => [], onEvent = async () => undefined }) {
16
+ return {
17
+ async runTurn(session, userInput) {
18
+ const cwd = session.cwd;
19
+ try {
20
+ session.lifecycle.status = 'running';
21
+ session.lifecycle.turn += 1;
22
+ session.lifecycle.lastUserInputAt = nowIso();
23
+ await appendRecord(cwd, session, {
24
+ id: makeId('usr'),
25
+ ts: nowIso(),
26
+ kind: 'user',
27
+ role: 'user',
28
+ content: userInput
29
+ });
30
+ await runHooks(hooks, 'beforeTurn', session, userInput);
31
+ for (let i = 0; i < MAX_LOOPS; i += 1) {
32
+ let chunks = 0;
33
+ let chars = 0;
34
+ let activeProvider = null;
35
+ let activeModel = null;
36
+ let sawModelStart = false;
37
+ let sawModelEnd = false;
38
+ let sawModelError = false;
39
+ let completion;
40
+ try {
41
+ completion = await model.complete(buildChatMessages(session, await instructions(session)), {
42
+ async onEvent(event) {
43
+ if (event.type === 'start') {
44
+ activeProvider = event.provider;
45
+ activeModel = event.model;
46
+ sawModelStart = true;
47
+ await onEvent({
48
+ type: 'model-start',
49
+ provider: event.provider,
50
+ model: event.model,
51
+ streaming: event.streaming
52
+ });
53
+ }
54
+ else if (event.type === 'text-delta') {
55
+ chunks += 1;
56
+ chars += event.text.length;
57
+ await onEvent({ type: 'model-progress', chunks, chars });
58
+ }
59
+ else if (event.type === 'end') {
60
+ if (activeProvider && activeModel) {
61
+ sawModelEnd = true;
62
+ await onEvent({ type: 'model-end', provider: activeProvider, model: activeModel });
63
+ }
64
+ }
65
+ else if (event.type === 'error') {
66
+ sawModelError = true;
67
+ await onEvent({ type: 'error', stage: 'model', message: event.message });
68
+ }
69
+ }
70
+ });
71
+ }
72
+ catch (error) {
73
+ if (!sawModelError) {
74
+ await onEvent({
75
+ type: 'error',
76
+ stage: 'model',
77
+ message: error instanceof Error ? error.message : String(error)
78
+ });
79
+ }
80
+ throw error;
81
+ }
82
+ if (!sawModelStart) {
83
+ await onEvent({
84
+ type: 'model-start',
85
+ provider: completion.provider,
86
+ model: completion.model,
87
+ streaming: false
88
+ });
89
+ }
90
+ if (!sawModelEnd) {
91
+ await onEvent({ type: 'model-end', provider: completion.provider, model: completion.model });
92
+ }
93
+ const rawContent = completion.content;
94
+ let action;
95
+ try {
96
+ action = parseModelAction(rawContent);
97
+ }
98
+ catch (error) {
99
+ await onEvent({
100
+ type: 'error',
101
+ stage: 'protocol',
102
+ message: error instanceof Error ? error.message : String(error)
103
+ });
104
+ throw error;
105
+ }
106
+ session.lifecycle.lastAssistantOutputAt = nowIso();
107
+ await appendRecord(cwd, session, {
108
+ id: makeId('ast'),
109
+ ts: nowIso(),
110
+ kind: 'assistant',
111
+ role: 'assistant',
112
+ content: rawContent,
113
+ action
114
+ });
115
+ await runHooks(hooks, 'afterAssistantAction', session, action, rawContent);
116
+ if ('message' in action) {
117
+ const finalMessage = action.message.trim() || '(no content)';
118
+ await runHooks(hooks, 'afterTurn', session, finalMessage);
119
+ await onEvent({ type: 'final', message: finalMessage });
120
+ return finalMessage;
121
+ }
122
+ const { definition } = registry.resolve(action);
123
+ await onEvent({ type: 'tool-start', tool: definition.name, preview: rawContent.slice(0, 120) });
124
+ let result = null;
125
+ let authorization = null;
126
+ try {
127
+ authorization = await policy.authorize(definition);
128
+ }
129
+ catch (error) {
130
+ const message = error instanceof Error ? error.message : String(error);
131
+ result = {
132
+ tool: definition.name,
133
+ status: 'error',
134
+ content: `TOOL_RESULT ${definition.name} ERROR\npolicy=${policy.mode}\n${message}`,
135
+ meta: {
136
+ policy: policy.mode,
137
+ error: error instanceof Error ? (error.stack ?? error.message) : message
138
+ }
139
+ };
140
+ }
141
+ if (result === null && authorization !== null && !authorization.allowed) {
142
+ result = {
143
+ tool: definition.name,
144
+ status: 'error',
145
+ content: `TOOL_RESULT ${definition.name} ERROR\npolicy=${policy.mode}\n${authorization.reason}`,
146
+ meta: {
147
+ policy: policy.mode,
148
+ reason: authorization.reason
149
+ }
150
+ };
151
+ }
152
+ else if (result === null && authorization !== null) {
153
+ await runHooks(hooks, 'beforeTool', session, action);
154
+ try {
155
+ result = await definition.execute(action, { cwd, session });
156
+ }
157
+ catch (error) {
158
+ const message = error instanceof Error ? error.message : String(error);
159
+ result = {
160
+ tool: definition.name,
161
+ status: 'error',
162
+ content: `TOOL_RESULT ${definition.name} ERROR\n${message}`,
163
+ meta: {
164
+ error: error instanceof Error ? (error.stack ?? error.message) : message
165
+ }
166
+ };
167
+ }
168
+ }
169
+ if (result === null) {
170
+ throw new Error(`No tool result produced for ${definition.name}`);
171
+ }
172
+ await appendRecord(cwd, session, {
173
+ id: makeId('tool'),
174
+ ts: nowIso(),
175
+ kind: 'tool',
176
+ role: 'user',
177
+ tool: result.tool,
178
+ status: result.status,
179
+ content: result.content,
180
+ meta: result.meta
181
+ });
182
+ await runHooks(hooks, 'afterTool', session, result);
183
+ await onEvent({ type: 'tool-end', tool: result.tool, status: result.status });
184
+ }
185
+ throw new Error('Exceeded action loop limit');
186
+ }
187
+ finally {
188
+ session.lifecycle.status = 'idle';
189
+ await saveSession(cwd, session);
190
+ }
191
+ }
192
+ };
193
+ }
@@ -0,0 +1,198 @@
1
+ import crypto from 'node:crypto';
2
+ import { promises as fs } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { APP_DIR, DEFAULT_MODEL_BASE_URL, DEFAULT_MODEL_PROVIDER, OLLAMA_DEFAULT_BASE_URL, SESSION_FILE, SESSION_VERSION } from '../config.js';
5
+ import { isProviderName } from '../model/registry.js';
6
+ function isSessionRecord(value) {
7
+ if (!value || typeof value !== 'object') {
8
+ return false;
9
+ }
10
+ const record = value;
11
+ if (typeof record.id !== 'string' || typeof record.ts !== 'string' || typeof record.kind !== 'string' || typeof record.role !== 'string') {
12
+ return false;
13
+ }
14
+ if (record.kind === 'system' || record.kind === 'user') {
15
+ return typeof record.content === 'string';
16
+ }
17
+ if (record.kind === 'assistant') {
18
+ return record.role === 'assistant' && typeof record.content === 'string' && 'action' in record;
19
+ }
20
+ if (record.kind === 'tool') {
21
+ return (record.role === 'user' &&
22
+ typeof record.tool === 'string' &&
23
+ (record.status === 'ok' || record.status === 'error') &&
24
+ typeof record.content === 'string');
25
+ }
26
+ return false;
27
+ }
28
+ function defaultSessionModel() {
29
+ return {
30
+ provider: 'ollama',
31
+ model: 'auto',
32
+ baseUrl: OLLAMA_DEFAULT_BASE_URL
33
+ };
34
+ }
35
+ function isSessionModelLike(value) {
36
+ if (typeof value === 'string') {
37
+ return true;
38
+ }
39
+ const model = value;
40
+ return (!!value &&
41
+ typeof value === 'object' &&
42
+ typeof model.provider === 'string' &&
43
+ isProviderName(model.provider) &&
44
+ typeof model.model === 'string' &&
45
+ (model.baseUrl === undefined || typeof model.baseUrl === 'string'));
46
+ }
47
+ function normalizeSessionModel(value) {
48
+ if (typeof value === 'string') {
49
+ return {
50
+ provider: DEFAULT_MODEL_PROVIDER,
51
+ model: value,
52
+ baseUrl: DEFAULT_MODEL_BASE_URL
53
+ };
54
+ }
55
+ if (isSessionModelLike(value) && typeof value !== 'string') {
56
+ return value;
57
+ }
58
+ return defaultSessionModel();
59
+ }
60
+ export function sessionPath(cwd) {
61
+ return path.join(cwd, APP_DIR, SESSION_FILE);
62
+ }
63
+ export function makeId(prefix) {
64
+ return `${prefix}_${crypto.randomUUID()}`;
65
+ }
66
+ export function nowIso() {
67
+ return new Date().toISOString();
68
+ }
69
+ export function createSession(cwd) {
70
+ const now = nowIso();
71
+ return {
72
+ version: SESSION_VERSION,
73
+ app: 'cliq',
74
+ model: defaultSessionModel(),
75
+ cwd,
76
+ createdAt: now,
77
+ updatedAt: now,
78
+ lifecycle: { status: 'idle', turn: 0 },
79
+ records: []
80
+ };
81
+ }
82
+ function isSession(value) {
83
+ return (!!value &&
84
+ typeof value === 'object' &&
85
+ typeof value.version === 'number' &&
86
+ value.app === 'cliq' &&
87
+ isSessionModelLike(value.model) &&
88
+ typeof value.cwd === 'string' &&
89
+ typeof value.createdAt === 'string' &&
90
+ typeof value.updatedAt === 'string' &&
91
+ !!value.lifecycle &&
92
+ typeof value.lifecycle === 'object' &&
93
+ (value.lifecycle.status === 'idle' || value.lifecycle.status === 'running') &&
94
+ typeof value.lifecycle.turn === 'number' &&
95
+ Array.isArray(value.records) &&
96
+ value.records.every((record) => isSessionRecord(record)));
97
+ }
98
+ function stripSeededSystemPrompt(records, sourceVersion = 0) {
99
+ if (sourceVersion > 2) {
100
+ return records;
101
+ }
102
+ return records.filter((record, index) => {
103
+ return !(index === 0 &&
104
+ record.kind === 'system' &&
105
+ record.role === 'system');
106
+ });
107
+ }
108
+ function normalizeSession(session) {
109
+ const records = stripSeededSystemPrompt(session.records, session.version);
110
+ const version = Math.max(session.version, SESSION_VERSION);
111
+ const rawModel = session.model;
112
+ const model = normalizeSessionModel(rawModel);
113
+ const modelChanged = model !== rawModel;
114
+ if (version === session.version && records.length === session.records.length && !modelChanged) {
115
+ return session;
116
+ }
117
+ return {
118
+ ...session,
119
+ version,
120
+ model,
121
+ records
122
+ };
123
+ }
124
+ function migrateLegacySession(cwd, legacy) {
125
+ const session = createSession(cwd);
126
+ session.createdAt = legacy.createdAt ?? session.createdAt;
127
+ session.updatedAt = legacy.updatedAt ?? session.updatedAt;
128
+ session.records = [];
129
+ for (const message of legacy.messages ?? []) {
130
+ const ts = nowIso();
131
+ if (message.role === 'system' && typeof message.content === 'string') {
132
+ session.records.push({ id: makeId('sys'), ts, kind: 'system', role: 'system', content: message.content });
133
+ }
134
+ else if (message.role === 'user' && typeof message.content === 'string') {
135
+ session.records.push({ id: makeId('usr'), ts, kind: 'user', role: 'user', content: message.content });
136
+ }
137
+ else if (message.role === 'assistant') {
138
+ session.records.push({
139
+ id: makeId('ast'),
140
+ ts,
141
+ kind: 'assistant',
142
+ role: 'assistant',
143
+ content: message.content ?? '',
144
+ action: null
145
+ });
146
+ }
147
+ else if (message.role === 'tool') {
148
+ session.records.push({
149
+ id: makeId('tool'),
150
+ ts,
151
+ kind: 'tool',
152
+ role: 'user',
153
+ tool: message.name === 'edit' ? 'edit' : 'bash',
154
+ // Older sessions may not store tool status, so default only when absent.
155
+ status: message.status ?? 'ok',
156
+ content: message.content ?? ''
157
+ });
158
+ }
159
+ }
160
+ session.records = stripSeededSystemPrompt(session.records);
161
+ return session;
162
+ }
163
+ export async function saveSession(cwd, session) {
164
+ session.updatedAt = nowIso();
165
+ const target = sessionPath(cwd);
166
+ await fs.mkdir(path.dirname(target), { recursive: true });
167
+ await fs.writeFile(target, JSON.stringify(session, null, 2));
168
+ }
169
+ export async function appendRecord(cwd, session, record) {
170
+ session.records.push(record);
171
+ await saveSession(cwd, session);
172
+ }
173
+ export async function ensureSession(cwd) {
174
+ const target = sessionPath(cwd);
175
+ await fs.mkdir(path.dirname(target), { recursive: true });
176
+ try {
177
+ const raw = JSON.parse(await fs.readFile(target, 'utf8'));
178
+ if (isSession(raw)) {
179
+ const normalized = normalizeSession(raw);
180
+ if (normalized !== raw) {
181
+ await saveSession(cwd, normalized);
182
+ }
183
+ return normalized;
184
+ }
185
+ const migrated = migrateLegacySession(cwd, raw);
186
+ await saveSession(cwd, migrated);
187
+ return migrated;
188
+ }
189
+ catch {
190
+ const session = createSession(cwd);
191
+ await saveSession(cwd, session);
192
+ return session;
193
+ }
194
+ }
195
+ export async function ensureFresh(cwd) {
196
+ await fs.rm(path.join(cwd, APP_DIR), { recursive: true, force: true });
197
+ return ensureSession(cwd);
198
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,51 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { APP_DIR } from '../config.js';
4
+ import { resolveWorkspacePath } from '../tools/path.js';
5
+ export function mergeSkillNames(defaultSkills, cliSkills) {
6
+ return [...new Set([...defaultSkills, ...cliSkills])];
7
+ }
8
+ function isValidSkillName(name) {
9
+ return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name);
10
+ }
11
+ function parseSkillMarkdown(raw) {
12
+ const match = raw.match(/^\uFEFF?---\r?\n([\s\S]*?)\r?\n---\r?\n+([\s\S]*)$/);
13
+ if (!match) {
14
+ throw new Error('Skill file must begin with frontmatter');
15
+ }
16
+ const headers = Object.fromEntries(match[1]
17
+ .split(/\r?\n/)
18
+ .filter(Boolean)
19
+ .map((line) => {
20
+ const [key, ...rest] = line.split(':');
21
+ return [key.trim(), rest.join(':').trim()];
22
+ }));
23
+ if (!headers.name) {
24
+ throw new Error('Skill file must declare a name');
25
+ }
26
+ const prompt = match[2].trim();
27
+ if (!prompt) {
28
+ throw new Error('Skill prompt body must not be empty');
29
+ }
30
+ return {
31
+ name: headers.name,
32
+ description: headers.description ?? null,
33
+ prompt
34
+ };
35
+ }
36
+ export async function loadSkills(cwd, names) {
37
+ const loaded = [];
38
+ for (const name of names) {
39
+ if (!isValidSkillName(name)) {
40
+ throw new Error(`Invalid skill name: ${name}`);
41
+ }
42
+ const { targetRealPath } = await resolveWorkspacePath(cwd, path.join(APP_DIR, 'skills', name, 'SKILL.md'));
43
+ const raw = await fs.readFile(targetRealPath, 'utf8');
44
+ const skill = parseSkillMarkdown(raw);
45
+ if (skill.name !== name) {
46
+ throw new Error(`Skill ${name} must declare matching frontmatter name`);
47
+ }
48
+ loaded.push(skill);
49
+ }
50
+ return loaded;
51
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,71 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { BASH_TIMEOUT_MS, MAX_OUTPUT } from '../config.js';
3
+ function clip(text) {
4
+ return text.length <= MAX_OUTPUT ? text : text.slice(-MAX_OUTPUT);
5
+ }
6
+ export const bashTool = {
7
+ name: 'bash',
8
+ access: 'exec',
9
+ supports(action) {
10
+ return typeof action.bash === 'string';
11
+ },
12
+ async execute(action, context) {
13
+ return await new Promise((resolve) => {
14
+ const child = spawn('bash', ['-lc', action.bash], { cwd: context.cwd, env: process.env });
15
+ let out = '';
16
+ let timedOut = false;
17
+ let settled = false;
18
+ let killTimer;
19
+ const finish = (result) => {
20
+ if (settled) {
21
+ return;
22
+ }
23
+ settled = true;
24
+ clearTimeout(timer);
25
+ if (killTimer) {
26
+ clearTimeout(killTimer);
27
+ }
28
+ resolve(result);
29
+ };
30
+ const onData = (chunk) => {
31
+ out += chunk.toString();
32
+ out = clip(out);
33
+ };
34
+ child.stdout?.on('data', onData);
35
+ child.stderr?.on('data', onData);
36
+ const timer = setTimeout(() => {
37
+ timedOut = true;
38
+ child.kill('SIGTERM');
39
+ killTimer = setTimeout(() => {
40
+ if (!settled) {
41
+ child.kill('SIGKILL');
42
+ }
43
+ }, 250);
44
+ out = clip(`${out}\n[process timed out after ${BASH_TIMEOUT_MS}ms]`);
45
+ }, BASH_TIMEOUT_MS);
46
+ child.on('error', (error) => {
47
+ finish({
48
+ tool: 'bash',
49
+ status: 'error',
50
+ meta: { exit: null, signal: error.code ?? 'error', timed_out: false },
51
+ content: [`TOOL_RESULT bash ERROR`, `$ ${action.bash}`, `(exit=null signal=${error.code ?? 'error'})`, error.message]
52
+ .filter(Boolean)
53
+ .join('\n')
54
+ .trim()
55
+ });
56
+ });
57
+ child.on('close', (code, signal) => {
58
+ const status = code === 0 && !timedOut ? 'ok' : 'error';
59
+ finish({
60
+ tool: 'bash',
61
+ status,
62
+ meta: { exit: code ?? null, signal: signal ?? 'none', timed_out: timedOut },
63
+ content: [`TOOL_RESULT bash ${status.toUpperCase()}`, `$ ${action.bash}`, `(exit=${code ?? 'null'} signal=${signal ?? 'none'})`, out]
64
+ .filter(Boolean)
65
+ .join('\n')
66
+ .trim()
67
+ });
68
+ });
69
+ });
70
+ }
71
+ };
@@ -0,0 +1,61 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import { resolveWorkspacePath, WORKSPACE_PATH_ERROR } from './path.js';
3
+ export const editTool = {
4
+ name: 'edit',
5
+ access: 'write',
6
+ supports(action) {
7
+ return typeof action.edit === 'object' && !!action.edit;
8
+ },
9
+ async execute(action, context) {
10
+ let targetRealPath;
11
+ let relativePath;
12
+ try {
13
+ ({ targetRealPath, relativePath } = await resolveWorkspacePath(context.cwd, action.edit.path));
14
+ }
15
+ catch (error) {
16
+ const message = error instanceof Error ? error.message : String(error);
17
+ const displayError = message === WORKSPACE_PATH_ERROR
18
+ ? 'edit.path must be a workspace-relative path inside the workspace'
19
+ : message;
20
+ const meta = { path: action.edit.path, error: displayError };
21
+ return {
22
+ tool: 'edit',
23
+ status: 'error',
24
+ meta,
25
+ content: `TOOL_RESULT edit ERROR\npath=${action.edit.path}\n${displayError}`
26
+ };
27
+ }
28
+ try {
29
+ const current = await fs.readFile(targetRealPath, 'utf8');
30
+ const matches = current.split(action.edit.old_text).length - 1;
31
+ if (matches !== 1) {
32
+ const error = `expected old_text to match exactly once, but matched ${matches} times`;
33
+ const meta = { path: relativePath, matches, error };
34
+ return {
35
+ tool: 'edit',
36
+ status: 'error',
37
+ meta,
38
+ content: `TOOL_RESULT edit ERROR\npath=${relativePath}\n${error}`
39
+ };
40
+ }
41
+ const meta = { path: relativePath };
42
+ await fs.writeFile(targetRealPath, current.replace(action.edit.old_text, action.edit.new_text), 'utf8');
43
+ return {
44
+ tool: 'edit',
45
+ status: 'ok',
46
+ meta,
47
+ content: `TOOL_RESULT edit OK\npath=${relativePath}\nreplaced exact text span successfully`
48
+ };
49
+ }
50
+ catch (error) {
51
+ const message = error instanceof Error ? error.message : String(error);
52
+ const meta = { path: relativePath, error: message };
53
+ return {
54
+ tool: 'edit',
55
+ status: 'error',
56
+ meta,
57
+ content: `TOOL_RESULT edit ERROR\npath=${relativePath}\n${message}`
58
+ };
59
+ }
60
+ }
61
+ };