c0de-agent 1.4.0 → 1.6.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.
- package/dist/core/agent.js +4 -0
- package/dist/core/config.js +1 -1
- package/dist/core/index.d.ts +3 -3
- package/dist/core/index.js +1 -1
- package/dist/core/loop.js +9 -0
- package/dist/core/prompt-registry.d.ts +2 -2
- package/dist/core/prompt-registry.js +42 -3
- package/dist/core/slash.js +102 -6
- package/dist/core/types.d.ts +10 -1
- package/dist/core/workflow.d.ts +1 -1
- package/dist/core/workflow.js +56 -5
- package/dist/core/workflows/discovery.d.ts +23 -2
- package/dist/core/workflows/discovery.js +38 -2
- package/dist/core/workflows/index.d.ts +3 -2
- package/dist/core/workflows/index.js +2 -2
- package/dist/core/workflows/registry.d.ts +8 -1
- package/dist/core/workflows/registry.js +21 -1
- package/dist/core/workflows/runtime.d.ts +3 -0
- package/dist/core/workflows/runtime.js +2 -2
- package/dist/project/resolve.d.ts +75 -0
- package/dist/project/resolve.js +253 -1
- package/dist/server/app.js +3 -0
- package/dist/server/context.js +2 -1
- package/dist/server/dev.js +3 -1
- package/dist/server/routes/chat.js +12 -0
- package/dist/server/routes/commands.js +1 -0
- package/dist/server/routes/files.js +252 -4
- package/dist/server/routes/terminal.js +2 -1
- package/dist/server/routes/todo.d.ts +4 -0
- package/dist/server/routes/todo.js +107 -0
- package/dist/server/routes/workflows.js +83 -10
- package/dist/server/server.d.ts +8 -1
- package/dist/server/server.js +113 -23
- package/dist/server/terminal/pty-manager.d.ts +14 -0
- package/dist/server/terminal/pty-manager.js +105 -7
- package/dist/shared/types/agent.d.ts +9 -0
- package/dist/shared/types/config.d.ts +6 -0
- package/dist/shared/types/tool.d.ts +13 -0
- package/dist/tools/builtin/todo.d.ts +67 -0
- package/dist/tools/builtin/todo.js +517 -0
- package/dist/tools/index.d.ts +2 -0
- package/dist/tools/index.js +3 -0
- package/dist/tools/types.d.ts +32 -1
- package/package.json +2 -1
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
// todo tool: phased task tracking with 7 operations, fuzzy matching, and
|
|
2
|
+
// Markdown round-trip. Logic ported from oh-my-pi, adapted to c0de-agent's
|
|
3
|
+
// stateless ToolDef + ToolContext.todoState hook pattern.
|
|
4
|
+
// =============================================================================
|
|
5
|
+
// State helpers
|
|
6
|
+
// =============================================================================
|
|
7
|
+
function findTaskByContent(phases, content) {
|
|
8
|
+
for (const phase of phases) {
|
|
9
|
+
const task = phase.tasks.find((t) => t.content === content);
|
|
10
|
+
if (task)
|
|
11
|
+
return { task, phase };
|
|
12
|
+
}
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
function findPhaseByName(phases, name) {
|
|
16
|
+
return phases.find((phase) => phase.name === name);
|
|
17
|
+
}
|
|
18
|
+
function cloneTask(task) {
|
|
19
|
+
return { content: task.content, status: task.status };
|
|
20
|
+
}
|
|
21
|
+
/** Deep-clone phases (mutation-safe). */
|
|
22
|
+
export function clonePhases(phases) {
|
|
23
|
+
return phases.map((phase) => ({ name: phase.name, tasks: phase.tasks.map(cloneTask) }));
|
|
24
|
+
}
|
|
25
|
+
function todoTransitionKey(phase, content) {
|
|
26
|
+
return `${phase}\u0000${content}`;
|
|
27
|
+
}
|
|
28
|
+
function getCompletionTransitions(previous, updated) {
|
|
29
|
+
const previousStatuses = new Map();
|
|
30
|
+
for (const phase of previous) {
|
|
31
|
+
for (const task of phase.tasks) {
|
|
32
|
+
previousStatuses.set(todoTransitionKey(phase.name, task.content), task.status);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const transitions = [];
|
|
36
|
+
for (const phase of updated) {
|
|
37
|
+
for (const task of phase.tasks) {
|
|
38
|
+
if (task.status !== 'completed')
|
|
39
|
+
continue;
|
|
40
|
+
const prev = previousStatuses.get(todoTransitionKey(phase.name, task.content));
|
|
41
|
+
if (prev && prev !== 'completed') {
|
|
42
|
+
transitions.push({ phase: phase.name, content: task.content });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return transitions;
|
|
47
|
+
}
|
|
48
|
+
/** Ensure at most one in_progress task: demote extras, or auto-promote the
|
|
49
|
+
* first pending task if none is in_progress. Mutates in place. */
|
|
50
|
+
function normalizeInProgressTask(phases) {
|
|
51
|
+
const orderedTasks = phases.flatMap((phase) => phase.tasks);
|
|
52
|
+
if (orderedTasks.length === 0)
|
|
53
|
+
return;
|
|
54
|
+
const inProgressTasks = orderedTasks.filter((task) => task.status === 'in_progress');
|
|
55
|
+
if (inProgressTasks.length > 1) {
|
|
56
|
+
for (const task of inProgressTasks.slice(1)) {
|
|
57
|
+
task.status = 'pending';
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (inProgressTasks.length > 0)
|
|
61
|
+
return;
|
|
62
|
+
const firstPending = orderedTasks.find((task) => task.status === 'pending');
|
|
63
|
+
if (firstPending)
|
|
64
|
+
firstPending.status = 'in_progress';
|
|
65
|
+
}
|
|
66
|
+
/** Return the active todo task, preferring in_progress over the first pending. */
|
|
67
|
+
export function nextActionableTask(phases) {
|
|
68
|
+
let firstPending;
|
|
69
|
+
for (const phase of phases) {
|
|
70
|
+
for (const task of phase.tasks) {
|
|
71
|
+
if (task.status === 'in_progress')
|
|
72
|
+
return task;
|
|
73
|
+
if (!firstPending && task.status === 'pending')
|
|
74
|
+
firstPending = task;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return firstPending;
|
|
78
|
+
}
|
|
79
|
+
// =============================================================================
|
|
80
|
+
// Fuzzy match
|
|
81
|
+
// =============================================================================
|
|
82
|
+
/** Minimum overlap (after normalization) required for a substring match.
|
|
83
|
+
* Six chars admits single-word identifiers like "review" / "Sonnet" without
|
|
84
|
+
* admitting tiny common substrings like "test" / "fix". */
|
|
85
|
+
const TODO_DESCRIPTION_MIN_OVERLAP = 6;
|
|
86
|
+
function normalizeForTodoMatch(value) {
|
|
87
|
+
return value
|
|
88
|
+
.toLowerCase()
|
|
89
|
+
.replace(/[^\p{L}\p{N}]+/gu, ' ')
|
|
90
|
+
.trim();
|
|
91
|
+
}
|
|
92
|
+
/** Report whether `content` likely names the same work as any entry in
|
|
93
|
+
* `descriptions`. Normalize-then-equal first, with a substring fallback
|
|
94
|
+
* in either direction (≥6 char overlap on the contained side). */
|
|
95
|
+
export function todoMatchesAnyDescription(content, descriptions) {
|
|
96
|
+
const target = normalizeForTodoMatch(content);
|
|
97
|
+
if (!target)
|
|
98
|
+
return false;
|
|
99
|
+
for (const desc of descriptions) {
|
|
100
|
+
const candidate = normalizeForTodoMatch(desc);
|
|
101
|
+
if (!candidate)
|
|
102
|
+
continue;
|
|
103
|
+
if (target === candidate)
|
|
104
|
+
return true;
|
|
105
|
+
if (target.length >= TODO_DESCRIPTION_MIN_OVERLAP && candidate.includes(target))
|
|
106
|
+
return true;
|
|
107
|
+
if (candidate.length >= TODO_DESCRIPTION_MIN_OVERLAP && target.includes(candidate))
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
// =============================================================================
|
|
113
|
+
// Resolution helpers
|
|
114
|
+
// =============================================================================
|
|
115
|
+
function resolveTaskOrError(phases, content, errors) {
|
|
116
|
+
if (!content) {
|
|
117
|
+
errors.push('Missing task content');
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
const hit = findTaskByContent(phases, content);
|
|
121
|
+
if (!hit) {
|
|
122
|
+
if (/^task-\d+$/.test(content)) {
|
|
123
|
+
errors.push(`Task "${content}" not found. Tasks are referenced by content, not by IDs — pass the task's full text from the previous result.`);
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
const totalTasks = phases.reduce((sum, phase) => sum + phase.tasks.length, 0);
|
|
127
|
+
const hint = totalTasks === 0 ? ' (todo list is empty — was it replaced or not yet created?)' : '';
|
|
128
|
+
errors.push(`Task "${content}" not found${hint}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return hit;
|
|
132
|
+
}
|
|
133
|
+
function resolvePhaseOrError(phases, name, errors) {
|
|
134
|
+
if (!name) {
|
|
135
|
+
errors.push('Missing phase name');
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
const phase = findPhaseByName(phases, name);
|
|
139
|
+
if (!phase)
|
|
140
|
+
errors.push(`Phase "${name}" not found`);
|
|
141
|
+
return phase;
|
|
142
|
+
}
|
|
143
|
+
function getTaskTargets(phases, entry, errors) {
|
|
144
|
+
if ('task' in entry && entry.task) {
|
|
145
|
+
const hit = resolveTaskOrError(phases, entry.task, errors);
|
|
146
|
+
return hit ? [hit.task] : [];
|
|
147
|
+
}
|
|
148
|
+
if ('phase' in entry && entry.phase) {
|
|
149
|
+
const phase = resolvePhaseOrError(phases, entry.phase, errors);
|
|
150
|
+
return phase ? [...phase.tasks] : [];
|
|
151
|
+
}
|
|
152
|
+
return phases.flatMap((phase) => phase.tasks);
|
|
153
|
+
}
|
|
154
|
+
// =============================================================================
|
|
155
|
+
// Operations
|
|
156
|
+
// =============================================================================
|
|
157
|
+
/** Phase name for `init` given a flat `items` list with no explicit `phase`. */
|
|
158
|
+
const DEFAULT_INIT_PHASE = 'Tasks';
|
|
159
|
+
function initPhases(entry, errors) {
|
|
160
|
+
// Models routinely flatten single-phase init into {op:"init", items:[...]}
|
|
161
|
+
// instead of the canonical list: [{phase, items}]. Accept that shape.
|
|
162
|
+
const list = entry.list ??
|
|
163
|
+
(entry.items && entry.items.length > 0
|
|
164
|
+
? [{ phase: entry.phase ?? DEFAULT_INIT_PHASE, items: entry.items }]
|
|
165
|
+
: undefined);
|
|
166
|
+
if (!list) {
|
|
167
|
+
errors.push('Missing list for init operation');
|
|
168
|
+
return [];
|
|
169
|
+
}
|
|
170
|
+
const seenPhases = new Set();
|
|
171
|
+
const seenTasks = new Set();
|
|
172
|
+
for (const listEntry of list) {
|
|
173
|
+
if (seenPhases.has(listEntry.phase)) {
|
|
174
|
+
errors.push(`Duplicate phase "${listEntry.phase}" in init list`);
|
|
175
|
+
}
|
|
176
|
+
seenPhases.add(listEntry.phase);
|
|
177
|
+
for (const content of listEntry.items) {
|
|
178
|
+
if (seenTasks.has(content)) {
|
|
179
|
+
errors.push(`Duplicate task "${content}" in init list`);
|
|
180
|
+
}
|
|
181
|
+
seenTasks.add(content);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return list.map((listEntry) => ({
|
|
185
|
+
name: listEntry.phase,
|
|
186
|
+
tasks: listEntry.items.map((content) => ({ content, status: 'pending' })),
|
|
187
|
+
}));
|
|
188
|
+
}
|
|
189
|
+
function appendItems(phases, entry, errors) {
|
|
190
|
+
if (!entry.items || entry.items.length === 0) {
|
|
191
|
+
errors.push('Missing items for append operation');
|
|
192
|
+
return phases;
|
|
193
|
+
}
|
|
194
|
+
// Validate the whole batch before mutating.
|
|
195
|
+
const seen = new Set();
|
|
196
|
+
let hasDuplicate = false;
|
|
197
|
+
for (const content of entry.items) {
|
|
198
|
+
if (seen.has(content) || findTaskByContent(phases, content)) {
|
|
199
|
+
errors.push(`Task "${content}" already exists`);
|
|
200
|
+
hasDuplicate = true;
|
|
201
|
+
}
|
|
202
|
+
seen.add(content);
|
|
203
|
+
}
|
|
204
|
+
if (hasDuplicate)
|
|
205
|
+
return phases;
|
|
206
|
+
let phase = findPhaseByName(phases, entry.phase);
|
|
207
|
+
if (!phase) {
|
|
208
|
+
phase = { name: entry.phase, tasks: [] };
|
|
209
|
+
phases.push(phase);
|
|
210
|
+
}
|
|
211
|
+
for (const content of entry.items) {
|
|
212
|
+
phase.tasks.push({ content, status: 'pending' });
|
|
213
|
+
}
|
|
214
|
+
return phases;
|
|
215
|
+
}
|
|
216
|
+
function removeTasks(phases, entry, errors) {
|
|
217
|
+
if (entry.task) {
|
|
218
|
+
const hit = resolveTaskOrError(phases, entry.task, errors);
|
|
219
|
+
if (!hit)
|
|
220
|
+
return phases;
|
|
221
|
+
hit.phase.tasks = hit.phase.tasks.filter((candidate) => candidate !== hit.task);
|
|
222
|
+
return phases;
|
|
223
|
+
}
|
|
224
|
+
if (entry.phase) {
|
|
225
|
+
const phase = resolvePhaseOrError(phases, entry.phase, errors);
|
|
226
|
+
if (!phase)
|
|
227
|
+
return phases;
|
|
228
|
+
phase.tasks = [];
|
|
229
|
+
return phases;
|
|
230
|
+
}
|
|
231
|
+
// No task or phase specified: clear all.
|
|
232
|
+
for (const phase of phases) {
|
|
233
|
+
phase.tasks = [];
|
|
234
|
+
}
|
|
235
|
+
return phases;
|
|
236
|
+
}
|
|
237
|
+
function applyEntry(phases, entry, errors) {
|
|
238
|
+
switch (entry.op) {
|
|
239
|
+
case 'init':
|
|
240
|
+
return initPhases(entry, errors);
|
|
241
|
+
case 'start': {
|
|
242
|
+
const hit = resolveTaskOrError(phases, entry.task, errors);
|
|
243
|
+
if (!hit)
|
|
244
|
+
return phases;
|
|
245
|
+
for (const phase of phases) {
|
|
246
|
+
for (const candidate of phase.tasks) {
|
|
247
|
+
if (candidate.status === 'in_progress' && candidate !== hit.task) {
|
|
248
|
+
candidate.status = 'pending';
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
hit.task.status = 'in_progress';
|
|
253
|
+
return phases;
|
|
254
|
+
}
|
|
255
|
+
case 'done': {
|
|
256
|
+
for (const task of getTaskTargets(phases, entry, errors)) {
|
|
257
|
+
task.status = 'completed';
|
|
258
|
+
}
|
|
259
|
+
return phases;
|
|
260
|
+
}
|
|
261
|
+
case 'drop': {
|
|
262
|
+
for (const task of getTaskTargets(phases, entry, errors)) {
|
|
263
|
+
task.status = 'abandoned';
|
|
264
|
+
}
|
|
265
|
+
return phases;
|
|
266
|
+
}
|
|
267
|
+
case 'rm':
|
|
268
|
+
return removeTasks(phases, entry, errors);
|
|
269
|
+
case 'append':
|
|
270
|
+
return appendItems(phases, entry, errors);
|
|
271
|
+
case 'view':
|
|
272
|
+
return phases;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
/** Apply a single todo op to existing phases. Returns new phases + errors. */
|
|
276
|
+
function applyParams(phases, params) {
|
|
277
|
+
const errors = [];
|
|
278
|
+
const next = applyEntry(phases, params, errors);
|
|
279
|
+
normalizeInProgressTask(next);
|
|
280
|
+
return { phases: next, errors };
|
|
281
|
+
}
|
|
282
|
+
// =============================================================================
|
|
283
|
+
// Markdown round-trip
|
|
284
|
+
// =============================================================================
|
|
285
|
+
const STATUS_TO_MARKER = {
|
|
286
|
+
pending: ' ',
|
|
287
|
+
in_progress: '/',
|
|
288
|
+
completed: 'x',
|
|
289
|
+
abandoned: '-',
|
|
290
|
+
};
|
|
291
|
+
/** Render todo phases as a Markdown checklist suitable for editing/copying. */
|
|
292
|
+
export function phasesToMarkdown(phases) {
|
|
293
|
+
if (phases.length === 0)
|
|
294
|
+
return '# Todos\n';
|
|
295
|
+
const out = [];
|
|
296
|
+
for (let i = 0; i < phases.length; i++) {
|
|
297
|
+
if (i > 0)
|
|
298
|
+
out.push('');
|
|
299
|
+
const phase = phases[i];
|
|
300
|
+
out.push(`# ${phase.name}`);
|
|
301
|
+
for (const task of phase.tasks) {
|
|
302
|
+
out.push(`- [${STATUS_TO_MARKER[task.status]}] ${task.content}`);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return `${out.join('\n')}\n`;
|
|
306
|
+
}
|
|
307
|
+
const MARKER_TO_STATUS = {
|
|
308
|
+
' ': 'pending',
|
|
309
|
+
'': 'pending',
|
|
310
|
+
x: 'completed',
|
|
311
|
+
X: 'completed',
|
|
312
|
+
'/': 'in_progress',
|
|
313
|
+
'>': 'in_progress',
|
|
314
|
+
'-': 'abandoned',
|
|
315
|
+
'~': 'abandoned',
|
|
316
|
+
};
|
|
317
|
+
/** Parse a Markdown checklist back into todo phases. */
|
|
318
|
+
export function markdownToPhases(md) {
|
|
319
|
+
const errors = [];
|
|
320
|
+
const phases = [];
|
|
321
|
+
let currentPhase;
|
|
322
|
+
const lines = md.split(/\r?\n/);
|
|
323
|
+
for (let lineNum = 0; lineNum < lines.length; lineNum++) {
|
|
324
|
+
const raw = lines[lineNum];
|
|
325
|
+
const trimmed = raw.trim();
|
|
326
|
+
if (!trimmed)
|
|
327
|
+
continue;
|
|
328
|
+
const headingMatch = /^#{1,6}\s+(.+?)\s*$/.exec(trimmed);
|
|
329
|
+
if (headingMatch) {
|
|
330
|
+
currentPhase = { name: headingMatch[1].trim(), tasks: [] };
|
|
331
|
+
phases.push(currentPhase);
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
const taskMatch = /^[-*+]\s*\[(.?)\]\s+(.+?)\s*$/.exec(trimmed);
|
|
335
|
+
if (taskMatch) {
|
|
336
|
+
if (!currentPhase) {
|
|
337
|
+
currentPhase = { name: 'Todos', tasks: [] };
|
|
338
|
+
phases.push(currentPhase);
|
|
339
|
+
}
|
|
340
|
+
const marker = taskMatch[1] ?? '';
|
|
341
|
+
const status = MARKER_TO_STATUS[marker];
|
|
342
|
+
if (!status) {
|
|
343
|
+
errors.push(`Line ${lineNum + 1}: unknown status marker "[${marker}]" (use [ ], [x], [/], [-])`);
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
currentPhase.tasks.push({ content: taskMatch[2].trim(), status });
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
errors.push(`Line ${lineNum + 1}: unrecognized syntax "${trimmed}"`);
|
|
350
|
+
}
|
|
351
|
+
normalizeInProgressTask(phases);
|
|
352
|
+
return { phases, errors };
|
|
353
|
+
}
|
|
354
|
+
// =============================================================================
|
|
355
|
+
// Summary formatter
|
|
356
|
+
// =============================================================================
|
|
357
|
+
function formatSummary(phases, errors, readOnly = false) {
|
|
358
|
+
const tasks = phases.flatMap((phase) => phase.tasks);
|
|
359
|
+
if (tasks.length === 0) {
|
|
360
|
+
if (errors.length > 0)
|
|
361
|
+
return `Errors: ${errors.join('; ')}`;
|
|
362
|
+
return readOnly ? 'Todo list is empty.' : 'Todo list cleared.';
|
|
363
|
+
}
|
|
364
|
+
const remainingByPhase = phases
|
|
365
|
+
.map((phase) => ({
|
|
366
|
+
name: phase.name,
|
|
367
|
+
tasks: phase.tasks.filter((task) => task.status === 'pending' || task.status === 'in_progress'),
|
|
368
|
+
}))
|
|
369
|
+
.filter((phase) => phase.tasks.length > 0);
|
|
370
|
+
const remainingTasks = remainingByPhase.flatMap((phase) => phase.tasks.map((task) => ({ ...task, phase: phase.name })));
|
|
371
|
+
let currentIdx = phases.findIndex((phase) => phase.tasks.some((task) => task.status === 'pending' || task.status === 'in_progress'));
|
|
372
|
+
if (currentIdx === -1)
|
|
373
|
+
currentIdx = phases.length - 1;
|
|
374
|
+
const current = phases[currentIdx];
|
|
375
|
+
const done = current.tasks.filter((task) => task.status === 'completed' || task.status === 'abandoned').length;
|
|
376
|
+
const lines = [];
|
|
377
|
+
if (errors.length > 0)
|
|
378
|
+
lines.push(`Errors: ${errors.join('; ')}`);
|
|
379
|
+
if (remainingTasks.length === 0) {
|
|
380
|
+
lines.push('Remaining items: none.');
|
|
381
|
+
}
|
|
382
|
+
else {
|
|
383
|
+
lines.push(`Remaining items (${remainingTasks.length}):`);
|
|
384
|
+
for (const task of remainingTasks) {
|
|
385
|
+
lines.push(` - ${task.content} [${task.status}] (${task.phase})`);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
const closedAll = tasks.filter((task) => task.status === 'completed' || task.status === 'abandoned').length;
|
|
389
|
+
const workedAhead = phases.some((phase, idx) => idx > currentIdx &&
|
|
390
|
+
phase.tasks.some((task) => task.status === 'completed' || task.status === 'abandoned'));
|
|
391
|
+
lines.push(`Overall: ${closedAll}/${tasks.length} done, ${remainingTasks.length} open.`);
|
|
392
|
+
lines.push(`Active phase ${currentIdx + 1}/${phases.length} "${current.name}" (${done}/${current.tasks.length})${workedAhead
|
|
393
|
+
? ' — earliest phase with open tasks; the in-progress pointer auto-advances to the earliest open task on each completion, so it can sit behind out-of-order work (nothing was un-completed).'
|
|
394
|
+
: '.'}`);
|
|
395
|
+
for (const phase of phases) {
|
|
396
|
+
lines.push(` ${phase.name}:`);
|
|
397
|
+
for (const task of phase.tasks) {
|
|
398
|
+
const checkbox = task.status === 'completed' ? '[X]' : '[ ]';
|
|
399
|
+
const tag = task.status === 'in_progress'
|
|
400
|
+
? ' (in progress)'
|
|
401
|
+
: task.status === 'abandoned'
|
|
402
|
+
? ' (dropped)'
|
|
403
|
+
: '';
|
|
404
|
+
lines.push(` - ${checkbox} ${task.content}${tag}`);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
return lines.join('\n');
|
|
408
|
+
}
|
|
409
|
+
// =============================================================================
|
|
410
|
+
// Session resume: extract latest phases from messages
|
|
411
|
+
// =============================================================================
|
|
412
|
+
/** Extract the latest todo phases from stored messages (tool results).
|
|
413
|
+
* Scans backwards for the most recent `todo` tool result with phases metadata. */
|
|
414
|
+
export function getLatestTodoPhasesFromMessages(messages) {
|
|
415
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
416
|
+
const msg = messages[i];
|
|
417
|
+
if (msg.role !== 'tool')
|
|
418
|
+
continue;
|
|
419
|
+
for (let j = msg.content.length - 1; j >= 0; j--) {
|
|
420
|
+
const part = msg.content[j];
|
|
421
|
+
if (!part || part._tag !== 'tool_result')
|
|
422
|
+
continue;
|
|
423
|
+
if (part.tool !== 'todo')
|
|
424
|
+
continue;
|
|
425
|
+
const output = part.output;
|
|
426
|
+
if (!output || output._tag !== 'success')
|
|
427
|
+
continue;
|
|
428
|
+
const metadata = output.metadata;
|
|
429
|
+
if (metadata && Array.isArray(metadata.phases)) {
|
|
430
|
+
return clonePhases(metadata.phases);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
return [];
|
|
435
|
+
}
|
|
436
|
+
// =============================================================================
|
|
437
|
+
// Schema
|
|
438
|
+
// =============================================================================
|
|
439
|
+
const todoParameters = {
|
|
440
|
+
type: 'object',
|
|
441
|
+
description: 'Apply a single todo operation',
|
|
442
|
+
properties: {
|
|
443
|
+
op: {
|
|
444
|
+
type: 'string',
|
|
445
|
+
enum: ['init', 'start', 'done', 'rm', 'drop', 'append', 'view'],
|
|
446
|
+
description: 'Operation to apply',
|
|
447
|
+
},
|
|
448
|
+
list: {
|
|
449
|
+
type: 'array',
|
|
450
|
+
description: 'Phased task list (init only). Each entry has a phase name and task items.',
|
|
451
|
+
items: {
|
|
452
|
+
type: 'object',
|
|
453
|
+
properties: {
|
|
454
|
+
phase: { type: 'string', description: 'Phase name' },
|
|
455
|
+
items: {
|
|
456
|
+
type: 'array',
|
|
457
|
+
items: { type: 'string' },
|
|
458
|
+
minItems: 1,
|
|
459
|
+
description: 'Task content strings for this phase',
|
|
460
|
+
},
|
|
461
|
+
},
|
|
462
|
+
required: ['phase', 'items'],
|
|
463
|
+
},
|
|
464
|
+
},
|
|
465
|
+
task: { type: 'string', description: 'Task content (for start/done/drop/rm)' },
|
|
466
|
+
phase: { type: 'string', description: 'Phase name (for done/drop/rm/append, or init flat mode)' },
|
|
467
|
+
items: {
|
|
468
|
+
type: 'array',
|
|
469
|
+
items: { type: 'string' },
|
|
470
|
+
description: 'Tasks to append (append), or flat init items (init)',
|
|
471
|
+
},
|
|
472
|
+
},
|
|
473
|
+
required: ['op'],
|
|
474
|
+
additionalProperties: false,
|
|
475
|
+
};
|
|
476
|
+
// =============================================================================
|
|
477
|
+
// Tool definition
|
|
478
|
+
// =============================================================================
|
|
479
|
+
/** todo tool: phased task tracking with 7 operations.
|
|
480
|
+
* Permission: auto (no side effects beyond session state).
|
|
481
|
+
* State is held in-memory via ctx.todoState hook (dependency-reversal). */
|
|
482
|
+
export const todoTool = {
|
|
483
|
+
name: 'todo',
|
|
484
|
+
description: 'Manage a phased task list to track progress within a session. 7 operations: init (create/replace list), start (mark in progress), done (mark completed), drop (mark abandoned), rm (remove task/phase), append (add tasks to a phase), view (read-only). Tasks auto-promote: completing a task moves the in-progress pointer to the next pending task. Pass the task content text (NOT an ID) to target a specific task.',
|
|
485
|
+
parameters: todoParameters,
|
|
486
|
+
permission: 'auto',
|
|
487
|
+
execute: async (input, ctx) => {
|
|
488
|
+
const params = input;
|
|
489
|
+
const todoState = ctx.todoState;
|
|
490
|
+
if (!todoState) {
|
|
491
|
+
return { _tag: 'error', error: 'Todo state not available in this context' };
|
|
492
|
+
}
|
|
493
|
+
const previousPhases = clonePhases(todoState.get());
|
|
494
|
+
const readOnly = params.op === 'view';
|
|
495
|
+
const { phases: updated, errors } = readOnly
|
|
496
|
+
? { phases: previousPhases, errors: [] }
|
|
497
|
+
: applyParams(clonePhases(previousPhases), params);
|
|
498
|
+
// A batch with any error is discarded wholesale: persisting a half-applied
|
|
499
|
+
// batch makes the natural retry hit "already exists" for the ops that did land.
|
|
500
|
+
const failed = errors.length > 0;
|
|
501
|
+
const effective = failed ? previousPhases : updated;
|
|
502
|
+
const completedTasks = readOnly || failed ? [] : getCompletionTransitions(previousPhases, updated);
|
|
503
|
+
if (!readOnly && !failed) {
|
|
504
|
+
todoState.set(updated);
|
|
505
|
+
}
|
|
506
|
+
const output = formatSummary(effective, errors, readOnly);
|
|
507
|
+
const metadata = { phases: effective };
|
|
508
|
+
if (completedTasks.length > 0) {
|
|
509
|
+
metadata.completedTasks = completedTasks;
|
|
510
|
+
}
|
|
511
|
+
// Always return success: errors are surfaced in the output text so the
|
|
512
|
+
// LLM can read them and retry. This avoids skewing tool metrics (a
|
|
513
|
+
// "task not found" is a user error, not a tool failure) and preserves
|
|
514
|
+
// metadata.phases for the LLM to reference on retry.
|
|
515
|
+
return { _tag: 'success', output, metadata };
|
|
516
|
+
},
|
|
517
|
+
};
|
package/dist/tools/index.d.ts
CHANGED
|
@@ -9,6 +9,8 @@ export { grepTool } from './builtin/grep.js';
|
|
|
9
9
|
export { readTool } from './builtin/read.js';
|
|
10
10
|
export { createDefaultURLRegistry, createFileResolver, createSkillResolver, } from './builtin/resolvers.js';
|
|
11
11
|
export { taskTool } from './builtin/task.js';
|
|
12
|
+
export { todoTool } from './builtin/todo.js';
|
|
13
|
+
export type { TodoInput, TodoItem, TodoPhase, TodoStatus } from './builtin/todo.js';
|
|
12
14
|
export { writeTool } from './builtin/write.js';
|
|
13
15
|
export { yieldTool } from './builtin/yield.js';
|
|
14
16
|
export { executeTool } from './executor.js';
|
package/dist/tools/index.js
CHANGED
|
@@ -8,6 +8,7 @@ export { readTool } from './builtin/read.js';
|
|
|
8
8
|
// ── Builtin tools ───────────────────────────────────────────
|
|
9
9
|
export { createDefaultURLRegistry, createFileResolver, createSkillResolver, } from './builtin/resolvers.js';
|
|
10
10
|
export { taskTool } from './builtin/task.js';
|
|
11
|
+
export { todoTool } from './builtin/todo.js';
|
|
11
12
|
export { writeTool } from './builtin/write.js';
|
|
12
13
|
export { yieldTool } from './builtin/yield.js';
|
|
13
14
|
export { executeTool } from './executor.js';
|
|
@@ -28,6 +29,7 @@ import { globTool } from './builtin/glob.js';
|
|
|
28
29
|
import { grepTool } from './builtin/grep.js';
|
|
29
30
|
import { readTool } from './builtin/read.js';
|
|
30
31
|
import { taskTool } from './builtin/task.js';
|
|
32
|
+
import { todoTool } from './builtin/todo.js';
|
|
31
33
|
import { writeTool } from './builtin/write.js';
|
|
32
34
|
import { yieldTool } from './builtin/yield.js';
|
|
33
35
|
import { createToolRegistry, registerTool } from './registry.js';
|
|
@@ -47,6 +49,7 @@ export function createDefaultRegistry(config = DEFAULT_CONFIG) {
|
|
|
47
49
|
registerTool(reg, grepTool);
|
|
48
50
|
registerTool(reg, bashTool);
|
|
49
51
|
registerTool(reg, taskTool);
|
|
52
|
+
registerTool(reg, todoTool);
|
|
50
53
|
registerTool(reg, yieldTool);
|
|
51
54
|
registerTool(reg, createWebSearchTool(config.websearch));
|
|
52
55
|
for (const tool of dapTools)
|
package/dist/tools/types.d.ts
CHANGED
|
@@ -91,6 +91,37 @@ type BashInput = {
|
|
|
91
91
|
timeout?: number;
|
|
92
92
|
env?: Record<string, string>;
|
|
93
93
|
};
|
|
94
|
+
/** Input for the todo tool. */
|
|
95
|
+
type TodoInput = {
|
|
96
|
+
op: 'init';
|
|
97
|
+
list?: {
|
|
98
|
+
phase: string;
|
|
99
|
+
items: string[];
|
|
100
|
+
}[];
|
|
101
|
+
phase?: string;
|
|
102
|
+
items?: string[];
|
|
103
|
+
} | {
|
|
104
|
+
op: 'start';
|
|
105
|
+
task: string;
|
|
106
|
+
} | {
|
|
107
|
+
op: 'done';
|
|
108
|
+
task?: string;
|
|
109
|
+
phase?: string;
|
|
110
|
+
} | {
|
|
111
|
+
op: 'drop';
|
|
112
|
+
task?: string;
|
|
113
|
+
phase?: string;
|
|
114
|
+
} | {
|
|
115
|
+
op: 'rm';
|
|
116
|
+
task?: string;
|
|
117
|
+
phase?: string;
|
|
118
|
+
} | {
|
|
119
|
+
op: 'append';
|
|
120
|
+
phase: string;
|
|
121
|
+
items: string[];
|
|
122
|
+
} | {
|
|
123
|
+
op: 'view';
|
|
124
|
+
};
|
|
94
125
|
/** A single grep match. */
|
|
95
126
|
type GrepMatch = {
|
|
96
127
|
file: string;
|
|
@@ -98,4 +129,4 @@ type GrepMatch = {
|
|
|
98
129
|
text: string;
|
|
99
130
|
match: string;
|
|
100
131
|
};
|
|
101
|
-
export type { BashInput, ChatTool, EditInput, GlobInput, GrepInput, GrepMatch, JSONSchema, PermissionChecker, PermissionResult, ReadInput, ToolContext, ToolDef, ToolExecutor, ToolFactory, ToolFactoryContext, ToolMode, ToolPermission, ToolRegistry, ToolResult, TruncateOptions, TruncateResult, ValidationResult, WriteInput, };
|
|
132
|
+
export type { BashInput, ChatTool, EditInput, GlobInput, GrepInput, GrepMatch, JSONSchema, PermissionChecker, PermissionResult, ReadInput, TodoInput, ToolContext, ToolDef, ToolExecutor, ToolFactory, ToolFactoryContext, ToolMode, ToolPermission, ToolRegistry, ToolResult, TruncateOptions, TruncateResult, ValidationResult, WriteInput, };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "c0de-agent",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"description": "Open-source AI coding assistant with Browser-Server architecture",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -107,6 +107,7 @@
|
|
|
107
107
|
"react-dom": "^19.2.7",
|
|
108
108
|
"react-router-dom": "^7.18.0",
|
|
109
109
|
"shiki": "^4.3.0",
|
|
110
|
+
"trash": "^10.1.1",
|
|
110
111
|
"undici": "^8.5.0",
|
|
111
112
|
"ws": "^8.21.0"
|
|
112
113
|
}
|