@parallel-cli/parallel 0.3.3
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/LICENSE +21 -0
- package/README.md +316 -0
- package/dist/agents/agent.js +518 -0
- package/dist/agents/tools.js +570 -0
- package/dist/commands.js +480 -0
- package/dist/config.js +163 -0
- package/dist/controller.js +703 -0
- package/dist/coordination/blackboard.js +225 -0
- package/dist/i18n.js +1087 -0
- package/dist/index.js +196 -0
- package/dist/llm/client.js +46 -0
- package/dist/pricing.js +76 -0
- package/dist/server.js +149 -0
- package/dist/skills.js +132 -0
- package/dist/types.js +1 -0
- package/dist/ui/AgentPanel.js +25 -0
- package/dist/ui/App.js +400 -0
- package/dist/ui/ApprovalPrompt.js +18 -0
- package/dist/ui/AttachApp.js +126 -0
- package/dist/ui/CommandInput.js +154 -0
- package/dist/ui/Md.js +40 -0
- package/dist/ui/QuestionPrompt.js +58 -0
- package/dist/ui/SettingsPanel.js +217 -0
- package/dist/ui/Spinner.js +12 -0
- package/dist/ui/Wizard.js +66 -0
- package/dist/ui/clipboard.js +36 -0
- package/dist/ui/theme.js +27 -0
- package/dist/ui/views.js +94 -0
- package/package.json +59 -0
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { exec } from 'node:child_process';
|
|
4
|
+
import * as Diff from 'diff';
|
|
5
|
+
const IGNORED = new Set(['node_modules', '.git', '.parallel', 'dist', '__pycache__', '.venv', 'venv']);
|
|
6
|
+
const MAX_OUTPUT = 12_000;
|
|
7
|
+
export const TOOL_DEFINITIONS = [
|
|
8
|
+
{
|
|
9
|
+
type: 'function',
|
|
10
|
+
function: {
|
|
11
|
+
name: 'list_files',
|
|
12
|
+
description: 'List project files (recursive, ignores node_modules/.git/dist). Use it first to understand the structure.',
|
|
13
|
+
parameters: {
|
|
14
|
+
type: 'object',
|
|
15
|
+
properties: {
|
|
16
|
+
path: { type: 'string', description: 'Relative subfolder (default: project root)' },
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
type: 'function',
|
|
23
|
+
function: {
|
|
24
|
+
name: 'read_file',
|
|
25
|
+
description: 'Read the current content of a file (with line numbers). Always re-read a file another agent just modified before relying on it.',
|
|
26
|
+
parameters: {
|
|
27
|
+
type: 'object',
|
|
28
|
+
properties: {
|
|
29
|
+
path: { type: 'string', description: 'Relative file path' },
|
|
30
|
+
},
|
|
31
|
+
required: ['path'],
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
type: 'function',
|
|
37
|
+
function: {
|
|
38
|
+
name: 'write_file',
|
|
39
|
+
description: 'Create or fully replace a file. NO locks: you may write to files other agents are working on. BUT if the file changed since your last read, the tool shows you the other agent’s diff and asks you to re-read so you INTEGRATE their changes (never erase them). Prefer edit_file for targeted changes.',
|
|
40
|
+
parameters: {
|
|
41
|
+
type: 'object',
|
|
42
|
+
properties: {
|
|
43
|
+
path: { type: 'string', description: 'Relative file path' },
|
|
44
|
+
content: { type: 'string', description: 'Full file content' },
|
|
45
|
+
},
|
|
46
|
+
required: ['path', 'content'],
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
type: 'function',
|
|
52
|
+
function: {
|
|
53
|
+
name: 'edit_file',
|
|
54
|
+
description: 'Modify a file by replacing exactly old_string with new_string (old_string must be unique). Ideal tool for parallel co-editing: your targeted edits coexist with other agents’ edits in the same file.',
|
|
55
|
+
parameters: {
|
|
56
|
+
type: 'object',
|
|
57
|
+
properties: {
|
|
58
|
+
path: { type: 'string' },
|
|
59
|
+
old_string: { type: 'string', description: 'Exact text to replace (unique in the file)' },
|
|
60
|
+
new_string: { type: 'string', description: 'New text' },
|
|
61
|
+
},
|
|
62
|
+
required: ['path', 'old_string', 'new_string'],
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
type: 'function',
|
|
68
|
+
function: {
|
|
69
|
+
name: 'search',
|
|
70
|
+
description: 'Search a pattern (regex) across project files. Returns file:line.',
|
|
71
|
+
parameters: {
|
|
72
|
+
type: 'object',
|
|
73
|
+
properties: {
|
|
74
|
+
pattern: { type: 'string', description: 'Regular expression to search' },
|
|
75
|
+
path: { type: 'string', description: 'Subfolder to search in (default: root)' },
|
|
76
|
+
},
|
|
77
|
+
required: ['pattern'],
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
type: 'function',
|
|
83
|
+
function: {
|
|
84
|
+
name: 'run_command',
|
|
85
|
+
description: 'Run a shell command at the project root (tests, build, install...). May require user approval. 120s timeout.',
|
|
86
|
+
parameters: {
|
|
87
|
+
type: 'object',
|
|
88
|
+
properties: {
|
|
89
|
+
command: { type: 'string', description: 'Shell command to run' },
|
|
90
|
+
},
|
|
91
|
+
required: ['command'],
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
type: 'function',
|
|
97
|
+
function: {
|
|
98
|
+
name: 'post_note',
|
|
99
|
+
description: "Send a note/instruction to another agent ('all' for everyone, an agent name, or 'user'). This is your coordination channel: announce your intentions on a shared file, ask another agent to adapt their approach, flag a problem in their work, align your interfaces.",
|
|
100
|
+
parameters: {
|
|
101
|
+
type: 'object',
|
|
102
|
+
properties: {
|
|
103
|
+
to: { type: 'string', description: "'all', 'user' or an agent name" },
|
|
104
|
+
content: { type: 'string', description: 'Note content (concise and actionable)' },
|
|
105
|
+
},
|
|
106
|
+
required: ['to', 'content'],
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
type: 'function',
|
|
112
|
+
function: {
|
|
113
|
+
name: 'update_status',
|
|
114
|
+
description: 'Update your current action, visible in real time by the other agents. Call it whenever your focus changes (e.g. "refactoring the auth function in src/api.ts").',
|
|
115
|
+
parameters: {
|
|
116
|
+
type: 'object',
|
|
117
|
+
properties: {
|
|
118
|
+
status: { type: 'string', description: 'Short description of your current action' },
|
|
119
|
+
},
|
|
120
|
+
required: ['status'],
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
type: 'function',
|
|
126
|
+
function: {
|
|
127
|
+
name: 'ask_user',
|
|
128
|
+
description: 'Ask the user a multiple-choice question when you are BLOCKED or need direction (ambiguous requirement, irreversible decision, several valid approaches). Provide 2-4 options and mark the one you recommend: if the user does not answer within 30 seconds, your recommended option is chosen automatically, so always make it a safe default you can act on. Use sparingly — at most 3 questions for the whole task. Never use it for things you can decide or verify yourself.',
|
|
129
|
+
parameters: {
|
|
130
|
+
type: 'object',
|
|
131
|
+
properties: {
|
|
132
|
+
question: { type: 'string', description: 'The question, short and specific' },
|
|
133
|
+
options: {
|
|
134
|
+
type: 'array',
|
|
135
|
+
items: { type: 'string' },
|
|
136
|
+
description: '2 to 4 possible answers, concise',
|
|
137
|
+
},
|
|
138
|
+
recommended: {
|
|
139
|
+
type: 'integer',
|
|
140
|
+
description: 'Index (0-based) of the option you recommend — auto-selected after 30s',
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
required: ['question', 'options', 'recommended'],
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
type: 'function',
|
|
149
|
+
function: {
|
|
150
|
+
name: 'load_skill',
|
|
151
|
+
description: 'Load a skill by name: returns its full instructions (conventions, checklists, procedures written by the user). The available skills are listed in your system prompt. Load a skill BEFORE working on anything its description covers.',
|
|
152
|
+
parameters: {
|
|
153
|
+
type: 'object',
|
|
154
|
+
properties: {
|
|
155
|
+
name: { type: 'string', description: 'Skill name, as listed in the catalog' },
|
|
156
|
+
},
|
|
157
|
+
required: ['name'],
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
type: 'function',
|
|
163
|
+
function: {
|
|
164
|
+
name: 'claim_files',
|
|
165
|
+
description: 'Declare the files/areas you are about to work on (e.g. ["src/auth/", "src/api.ts"]). This NEVER locks anything — it is a visible signal so the other agents avoid collisions and coordinate with you. Call it when you start a work area, and again when you move to another one (it replaces your previous claim).',
|
|
166
|
+
parameters: {
|
|
167
|
+
type: 'object',
|
|
168
|
+
properties: {
|
|
169
|
+
paths: {
|
|
170
|
+
type: 'array',
|
|
171
|
+
items: { type: 'string' },
|
|
172
|
+
description: '1 to 5 file paths or folder prefixes you are working on',
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
required: ['paths'],
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
type: 'function',
|
|
181
|
+
function: {
|
|
182
|
+
name: 'wait_for_agent',
|
|
183
|
+
description: 'Wait until another agent finishes (state done/error/stopped), then get its result summary. Use ONLY when you genuinely cannot progress without their output (e.g. you consume an interface they are still defining). Max 120s — if they are not done by then, you get their current status and must continue with another part of your task. Prefer making progress over waiting.',
|
|
184
|
+
parameters: {
|
|
185
|
+
type: 'object',
|
|
186
|
+
properties: {
|
|
187
|
+
name: { type: 'string', description: 'Name of the agent to wait for (e.g. "Agent-A")' },
|
|
188
|
+
timeout_s: { type: 'integer', description: 'Max seconds to wait (default 60, max 120)' },
|
|
189
|
+
},
|
|
190
|
+
required: ['name'],
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
type: 'function',
|
|
196
|
+
function: {
|
|
197
|
+
name: 'remember',
|
|
198
|
+
description: 'Append a durable fact to the shared project memory (.parallel/memory.md), injected into every future agent\'s system prompt. Use for non-obvious, lasting knowledge: conventions ("tests must run with --pool=forks"), decisions ("auth uses JWT in cookies, not headers"), pitfalls ("never edit dist/, it is generated"). NOT for task progress or temporary state.',
|
|
199
|
+
parameters: {
|
|
200
|
+
type: 'object',
|
|
201
|
+
properties: {
|
|
202
|
+
fact: { type: 'string', description: 'One concise, self-contained fact (1-2 sentences)' },
|
|
203
|
+
},
|
|
204
|
+
required: ['fact'],
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
type: 'function',
|
|
210
|
+
function: {
|
|
211
|
+
name: 'task_complete',
|
|
212
|
+
description: 'Call when your task is finished and verified. Notifies the other agents.',
|
|
213
|
+
parameters: {
|
|
214
|
+
type: 'object',
|
|
215
|
+
properties: {
|
|
216
|
+
summary: { type: 'string', description: 'Summary of what was accomplished' },
|
|
217
|
+
},
|
|
218
|
+
required: ['summary'],
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
];
|
|
223
|
+
export class ToolExecutor {
|
|
224
|
+
board;
|
|
225
|
+
agentId;
|
|
226
|
+
agentName;
|
|
227
|
+
projectRoot;
|
|
228
|
+
requestApproval;
|
|
229
|
+
requestQuestion;
|
|
230
|
+
skills;
|
|
231
|
+
/** Last content this agent has seen for each file — basis of adaptive merging. */
|
|
232
|
+
lastRead = new Map();
|
|
233
|
+
/** Questions already asked — capped at 3 per task. */
|
|
234
|
+
questionsAsked = 0;
|
|
235
|
+
constructor(board, agentId, agentName, projectRoot, requestApproval, requestQuestion, skills) {
|
|
236
|
+
this.board = board;
|
|
237
|
+
this.agentId = agentId;
|
|
238
|
+
this.agentName = agentName;
|
|
239
|
+
this.projectRoot = projectRoot;
|
|
240
|
+
this.requestApproval = requestApproval;
|
|
241
|
+
this.requestQuestion = requestQuestion;
|
|
242
|
+
this.skills = skills;
|
|
243
|
+
}
|
|
244
|
+
resolve(rel) {
|
|
245
|
+
const abs = path.resolve(this.projectRoot, rel);
|
|
246
|
+
if (!abs.startsWith(path.resolve(this.projectRoot))) {
|
|
247
|
+
throw new Error(`Path outside the project refused: ${rel}`);
|
|
248
|
+
}
|
|
249
|
+
return abs;
|
|
250
|
+
}
|
|
251
|
+
relOf(p) {
|
|
252
|
+
return path.relative(this.projectRoot, this.resolve(p)) || '.';
|
|
253
|
+
}
|
|
254
|
+
async execute(name, args) {
|
|
255
|
+
try {
|
|
256
|
+
switch (name) {
|
|
257
|
+
case 'list_files':
|
|
258
|
+
return this.listFiles(args?.path ?? '.');
|
|
259
|
+
case 'read_file':
|
|
260
|
+
return this.readFile(args.path);
|
|
261
|
+
case 'write_file':
|
|
262
|
+
return this.writeFile(args.path, args.content);
|
|
263
|
+
case 'edit_file':
|
|
264
|
+
return this.editFile(args.path, args.old_string, args.new_string);
|
|
265
|
+
case 'search':
|
|
266
|
+
return this.search(args.pattern, args?.path ?? '.');
|
|
267
|
+
case 'run_command':
|
|
268
|
+
return await this.runCommand(args.command);
|
|
269
|
+
case 'post_note':
|
|
270
|
+
this.board.addNote(this.agentName, args.to ?? 'all', String(args.content ?? ''));
|
|
271
|
+
return 'Note sent.';
|
|
272
|
+
case 'update_status':
|
|
273
|
+
this.board.updateAgent(this.agentId, { currentAction: String(args.status ?? '') });
|
|
274
|
+
return 'Status updated, visible to the other agents.';
|
|
275
|
+
case 'ask_user':
|
|
276
|
+
return await this.askUser(args);
|
|
277
|
+
case 'load_skill':
|
|
278
|
+
return this.loadSkill(String(args.name ?? ''));
|
|
279
|
+
case 'claim_files':
|
|
280
|
+
return this.claimFiles(args);
|
|
281
|
+
case 'wait_for_agent':
|
|
282
|
+
return await this.waitForAgent(args);
|
|
283
|
+
case 'remember':
|
|
284
|
+
return this.remember(String(args.fact ?? ''));
|
|
285
|
+
case 'task_complete':
|
|
286
|
+
// handled by the agent loop; return marker
|
|
287
|
+
return '__TASK_COMPLETE__';
|
|
288
|
+
default:
|
|
289
|
+
return `Unknown tool: ${name}`;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
catch (err) {
|
|
293
|
+
return `ERROR: ${err?.message ?? String(err)}`;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Ask the user a multiple-choice question. NEVER blocks forever: the UI shows
|
|
298
|
+
* a visible 30s countdown and falls back to the recommended option (auto-run).
|
|
299
|
+
*/
|
|
300
|
+
async askUser(args) {
|
|
301
|
+
const question = String(args.question ?? '').trim();
|
|
302
|
+
const options = Array.isArray(args.options) ? args.options.map((o) => String(o)).slice(0, 4) : [];
|
|
303
|
+
let recommended = Number.isInteger(args.recommended) ? args.recommended : 0;
|
|
304
|
+
if (recommended < 0 || recommended >= options.length)
|
|
305
|
+
recommended = 0;
|
|
306
|
+
if (!question || options.length < 2) {
|
|
307
|
+
return 'ERROR: ask_user needs a question and 2-4 options. Decide yourself if you can.';
|
|
308
|
+
}
|
|
309
|
+
if (this.questionsAsked >= 3) {
|
|
310
|
+
return `Question limit reached (3 per task). Choose the most reasonable option yourself and continue: I suggest "${options[recommended]}".`;
|
|
311
|
+
}
|
|
312
|
+
this.questionsAsked++;
|
|
313
|
+
this.board.setAgentState(this.agentId, 'waiting', `question: ${question.slice(0, 60)}`);
|
|
314
|
+
this.board.log(this.agentId, 'note', `❓ ${question}`);
|
|
315
|
+
const answer = await this.requestQuestion(this.agentId, question, options, recommended);
|
|
316
|
+
this.board.setAgentState(this.agentId, 'working');
|
|
317
|
+
return `The user answered: "${answer}". Act on this choice now (${3 - this.questionsAsked} question(s) left for this task).`;
|
|
318
|
+
}
|
|
319
|
+
/** Declare (advisory) work areas — visible to the user and the other agents. */
|
|
320
|
+
claimFiles(args) {
|
|
321
|
+
const paths = Array.isArray(args.paths) ? args.paths.map((p) => String(p)).slice(0, 5) : [];
|
|
322
|
+
if (paths.length === 0)
|
|
323
|
+
return 'ERROR: claim_files needs 1-5 paths.';
|
|
324
|
+
this.board.updateAgent(this.agentId, { claims: paths });
|
|
325
|
+
this.board.log(this.agentId, 'tool', `🚩 claims: ${paths.join(', ')}`);
|
|
326
|
+
return `Work area declared: ${paths.join(', ')}. The other agents see it in real time. Remember: this never locks anything — keep adapting to their diffs.`;
|
|
327
|
+
}
|
|
328
|
+
/** Wait (bounded) for another agent to finish, then return its summary. */
|
|
329
|
+
async waitForAgent(args) {
|
|
330
|
+
const target = this.board.getAgentByName(String(args.name ?? ''));
|
|
331
|
+
if (!target)
|
|
332
|
+
return `ERROR: no agent named "${args.name}".`;
|
|
333
|
+
if (target.id === this.agentId)
|
|
334
|
+
return 'ERROR: you cannot wait for yourself.';
|
|
335
|
+
const timeoutS = Math.min(Math.max(Number(args.timeout_s) || 60, 5), 120);
|
|
336
|
+
const TERMINAL = ['done', 'error', 'stopped'];
|
|
337
|
+
if (TERMINAL.includes(target.state)) {
|
|
338
|
+
return `${target.name} already finished [${target.state}]. Result: ${target.lastResult ?? '(no summary)'}`;
|
|
339
|
+
}
|
|
340
|
+
this.board.setAgentState(this.agentId, 'waiting', `waiting for ${target.name} (${timeoutS}s max)`);
|
|
341
|
+
const deadline = Date.now() + timeoutS * 1000;
|
|
342
|
+
while (Date.now() < deadline) {
|
|
343
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
344
|
+
const cur = this.board.agents.get(target.id);
|
|
345
|
+
if (!cur || TERMINAL.includes(cur.state)) {
|
|
346
|
+
this.board.setAgentState(this.agentId, 'working');
|
|
347
|
+
return `${target.name} finished [${cur?.state ?? 'gone'}]. Result: ${cur?.lastResult ?? '(no summary)'}. Re-read any file they touched before relying on it.`;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
this.board.setAgentState(this.agentId, 'working');
|
|
351
|
+
return `${target.name} is still [${target.state}] after ${timeoutS}s (current action: ${target.currentAction || '?'}). Do NOT wait again — progress on another part of your task, or coordinate by note.`;
|
|
352
|
+
}
|
|
353
|
+
/** Append a durable fact to the shared project memory. */
|
|
354
|
+
remember(fact) {
|
|
355
|
+
const f = fact.trim();
|
|
356
|
+
if (!f)
|
|
357
|
+
return 'ERROR: remember needs a non-empty fact.';
|
|
358
|
+
const file = path.join(this.projectRoot, '.parallel', 'memory.md');
|
|
359
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
360
|
+
if (!fs.existsSync(file)) {
|
|
361
|
+
fs.writeFileSync(file, '# Project memory\n\nDurable facts agents recorded. Injected into every agent\'s system prompt.\n\n');
|
|
362
|
+
}
|
|
363
|
+
const line = `- ${f} _(${this.agentName}, ${new Date().toISOString().slice(0, 10)})_\n`;
|
|
364
|
+
fs.appendFileSync(file, line);
|
|
365
|
+
this.board.log(this.agentId, 'tool', `🧠 remember: ${f.slice(0, 80)}`);
|
|
366
|
+
return 'Fact saved to the project memory. Every future agent will see it.';
|
|
367
|
+
}
|
|
368
|
+
/** Return the full body of a user-defined skill. */
|
|
369
|
+
loadSkill(name) {
|
|
370
|
+
const lower = name.trim().toLowerCase();
|
|
371
|
+
const skill = this.skills.find((s) => s.name === lower);
|
|
372
|
+
if (!skill) {
|
|
373
|
+
const list = this.skills.map((s) => s.name).join(', ') || '(none)';
|
|
374
|
+
return `ERROR: unknown skill "${name}". Available skills: ${list}.`;
|
|
375
|
+
}
|
|
376
|
+
this.board.log(this.agentId, 'tool', `🧩 skill loaded: ${skill.name}`);
|
|
377
|
+
return `[SKILL: ${skill.name}] (${skill.scope})\n${skill.body}\n[END SKILL — follow these instructions for the rest of your task]`;
|
|
378
|
+
}
|
|
379
|
+
listFiles(rel) {
|
|
380
|
+
const root = this.resolve(rel);
|
|
381
|
+
const out = [];
|
|
382
|
+
const walk = (dir, depth) => {
|
|
383
|
+
if (depth > 6 || out.length > 500)
|
|
384
|
+
return;
|
|
385
|
+
let entries;
|
|
386
|
+
try {
|
|
387
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
388
|
+
}
|
|
389
|
+
catch {
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
for (const e of entries) {
|
|
393
|
+
if (IGNORED.has(e.name) || e.name.startsWith('.git'))
|
|
394
|
+
continue;
|
|
395
|
+
const full = path.join(dir, e.name);
|
|
396
|
+
const relPath = path.relative(this.projectRoot, full);
|
|
397
|
+
if (e.isDirectory()) {
|
|
398
|
+
out.push(relPath + '/');
|
|
399
|
+
walk(full, depth + 1);
|
|
400
|
+
}
|
|
401
|
+
else {
|
|
402
|
+
let size = 0;
|
|
403
|
+
try {
|
|
404
|
+
size = fs.statSync(full).size;
|
|
405
|
+
}
|
|
406
|
+
catch { }
|
|
407
|
+
out.push(`${relPath} (${size}B)`);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
};
|
|
411
|
+
walk(root, 0);
|
|
412
|
+
if (out.length === 0)
|
|
413
|
+
return '(empty folder)';
|
|
414
|
+
return out.join('\n');
|
|
415
|
+
}
|
|
416
|
+
readFile(rel) {
|
|
417
|
+
const abs = this.resolve(rel);
|
|
418
|
+
const content = fs.readFileSync(abs, 'utf8');
|
|
419
|
+
this.lastRead.set(this.relOf(rel), content);
|
|
420
|
+
const lines = content.split('\n');
|
|
421
|
+
const numbered = lines.map((l, i) => `${String(i + 1).padStart(4)}|${l}`).join('\n');
|
|
422
|
+
return numbered.length > MAX_OUTPUT
|
|
423
|
+
? numbered.slice(0, MAX_OUTPUT) + `\n... (truncated, ${lines.length} lines total)`
|
|
424
|
+
: numbered;
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* Adaptive co-editing: writing is NEVER blocked by another agent.
|
|
428
|
+
* But if the file changed under you since your last read, you first get
|
|
429
|
+
* the other agent's diff so you can integrate it instead of erasing it.
|
|
430
|
+
*/
|
|
431
|
+
writeFile(rel, content) {
|
|
432
|
+
const relPath = this.relOf(rel);
|
|
433
|
+
const abs = this.resolve(rel);
|
|
434
|
+
const exists = fs.existsSync(abs);
|
|
435
|
+
const seen = this.lastRead.get(relPath);
|
|
436
|
+
if (exists) {
|
|
437
|
+
const current = fs.readFileSync(abs, 'utf8');
|
|
438
|
+
if (seen === undefined) {
|
|
439
|
+
const who = this.board.fileActivity.get(relPath);
|
|
440
|
+
return (`WARNING: ${relPath} already exists${who && who.agentId !== this.agentId ? ` and agent ${who.agentName} is working on it` : ''}. ` +
|
|
441
|
+
`Read it first (read_file) so you don't erase any work, then rewrite while integrating what exists.`);
|
|
442
|
+
}
|
|
443
|
+
if (current !== seen) {
|
|
444
|
+
this.lastRead.set(relPath, current); // sync view so next write passes
|
|
445
|
+
this.board.recordConflict(relPath); // repeated collisions escalate to the user
|
|
446
|
+
const who = this.board.fileActivity.get(relPath);
|
|
447
|
+
const author = who && who.agentId !== this.agentId ? who.agentName : 'another agent';
|
|
448
|
+
const patch = Diff.createPatch(relPath, seen, current, 'your read version', 'current version', {
|
|
449
|
+
context: 2,
|
|
450
|
+
});
|
|
451
|
+
const excerpt = patch.split('\n').slice(4, 40).join('\n');
|
|
452
|
+
return (`REAL-TIME ADAPTATION: ${relPath} was modified by ${author} while you were working. ` +
|
|
453
|
+
`Here are THEIR changes (to KEEP, do not erase them):\n${excerpt}\n` +
|
|
454
|
+
`Your view is now synchronized. Rewrite the file by MERGING your changes with theirs ` +
|
|
455
|
+
`(or use edit_file for targeted changes). If your work conflicts, send them a note.`);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
459
|
+
fs.writeFileSync(abs, content);
|
|
460
|
+
this.lastRead.set(relPath, content);
|
|
461
|
+
const before = exists ? (seen ?? '') : '';
|
|
462
|
+
this.board.addChange(this.agentId, relPath, before, content);
|
|
463
|
+
this.board.recordActivity(relPath, this.agentId, 'write');
|
|
464
|
+
this.board.log(this.agentId, 'tool', `✏ write ${relPath} (${content.length}B)`);
|
|
465
|
+
return `File written: ${relPath} (${content.split('\n').length} lines). The other agents see your diff in real time.`;
|
|
466
|
+
}
|
|
467
|
+
editFile(rel, oldStr, newStr) {
|
|
468
|
+
const relPath = this.relOf(rel);
|
|
469
|
+
const abs = this.resolve(rel);
|
|
470
|
+
const before = fs.readFileSync(abs, 'utf8');
|
|
471
|
+
const count = before.split(oldStr).length - 1;
|
|
472
|
+
if (count === 0) {
|
|
473
|
+
const seen = this.lastRead.get(relPath);
|
|
474
|
+
const who = this.board.fileActivity.get(relPath);
|
|
475
|
+
const collided = seen !== undefined && seen !== before && who && who.agentId !== this.agentId;
|
|
476
|
+
if (collided)
|
|
477
|
+
this.board.recordConflict(relPath);
|
|
478
|
+
const hint = collided
|
|
479
|
+
? ` The file was modified by ${who.agentName} in the meantime — re-read it (read_file) to see its current version and adapt your edit.`
|
|
480
|
+
: ' Re-read the file (read_file) to check the exact text.';
|
|
481
|
+
return `ERROR: old_string not found in ${relPath}.${hint}`;
|
|
482
|
+
}
|
|
483
|
+
if (count > 1) {
|
|
484
|
+
return `ERROR: old_string appears ${count} times in ${relPath}. Provide a longer, unique excerpt.`;
|
|
485
|
+
}
|
|
486
|
+
const after = before.replace(oldStr, newStr);
|
|
487
|
+
fs.writeFileSync(abs, after);
|
|
488
|
+
this.lastRead.set(relPath, after);
|
|
489
|
+
this.board.addChange(this.agentId, relPath, before, after);
|
|
490
|
+
this.board.recordActivity(relPath, this.agentId, 'edit');
|
|
491
|
+
this.board.log(this.agentId, 'tool', `✏ edit ${relPath}`);
|
|
492
|
+
return `File modified: ${relPath}. The other agents see your diff in real time.`;
|
|
493
|
+
}
|
|
494
|
+
search(pattern, rel) {
|
|
495
|
+
const root = this.resolve(rel);
|
|
496
|
+
let re;
|
|
497
|
+
try {
|
|
498
|
+
re = new RegExp(pattern);
|
|
499
|
+
}
|
|
500
|
+
catch {
|
|
501
|
+
return `ERROR: invalid regex: ${pattern}`;
|
|
502
|
+
}
|
|
503
|
+
const results = [];
|
|
504
|
+
const walk = (dir, depth) => {
|
|
505
|
+
if (depth > 6 || results.length > 100)
|
|
506
|
+
return;
|
|
507
|
+
let entries;
|
|
508
|
+
try {
|
|
509
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
510
|
+
}
|
|
511
|
+
catch {
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
for (const e of entries) {
|
|
515
|
+
if (IGNORED.has(e.name) || e.name.startsWith('.git'))
|
|
516
|
+
continue;
|
|
517
|
+
const full = path.join(dir, e.name);
|
|
518
|
+
if (e.isDirectory()) {
|
|
519
|
+
walk(full, depth + 1);
|
|
520
|
+
}
|
|
521
|
+
else {
|
|
522
|
+
let content;
|
|
523
|
+
try {
|
|
524
|
+
const stat = fs.statSync(full);
|
|
525
|
+
if (stat.size > 1_000_000)
|
|
526
|
+
continue;
|
|
527
|
+
content = fs.readFileSync(full, 'utf8');
|
|
528
|
+
}
|
|
529
|
+
catch {
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
const lines = content.split('\n');
|
|
533
|
+
for (let i = 0; i < lines.length && results.length <= 100; i++) {
|
|
534
|
+
if (re.test(lines[i])) {
|
|
535
|
+
results.push(`${path.relative(this.projectRoot, full)}:${i + 1}: ${lines[i].trim().slice(0, 160)}`);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
};
|
|
541
|
+
walk(root, 0);
|
|
542
|
+
return results.length > 0 ? results.join('\n') : 'No results.';
|
|
543
|
+
}
|
|
544
|
+
async runCommand(command) {
|
|
545
|
+
this.board.setAgentState(this.agentId, 'waiting', `approval: ${command.slice(0, 60)}`);
|
|
546
|
+
const approved = await this.requestApproval(this.agentId, command);
|
|
547
|
+
if (!approved) {
|
|
548
|
+
this.board.setAgentState(this.agentId, 'working');
|
|
549
|
+
return 'DENIED by the user. Do not retry this command; find another approach or continue without it.';
|
|
550
|
+
}
|
|
551
|
+
this.board.setAgentState(this.agentId, 'working', `$ ${command.slice(0, 60)}`);
|
|
552
|
+
this.board.log(this.agentId, 'tool', `$ ${command}`);
|
|
553
|
+
return new Promise((resolve) => {
|
|
554
|
+
exec(command, { cwd: this.projectRoot, timeout: 120_000, maxBuffer: 4 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
555
|
+
let out = '';
|
|
556
|
+
if (stdout)
|
|
557
|
+
out += stdout;
|
|
558
|
+
if (stderr)
|
|
559
|
+
out += (out ? '\n--- stderr ---\n' : '') + stderr;
|
|
560
|
+
if (err && err.killed)
|
|
561
|
+
out += '\n(process killed: 120s timeout)';
|
|
562
|
+
else if (err)
|
|
563
|
+
out += `\n(exit code: ${err.code ?? 1})`;
|
|
564
|
+
if (out.length > MAX_OUTPUT)
|
|
565
|
+
out = out.slice(0, MAX_OUTPUT) + '\n... (output truncated)';
|
|
566
|
+
resolve(out || '(no output, success)');
|
|
567
|
+
});
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
}
|