@hncode/hncode 0.1.11 → 0.3.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/LICENSE +674 -219
- package/README.md +1 -0
- package/README_CN.md +2 -0
- package/bin/install.js +1 -1
- package/package.json +1 -1
- package/src/agent-task.js +178 -0
- package/src/agent.js +96 -8
- package/src/colors.js +14 -0
- package/src/config.js +128 -0
- package/src/index.js +7 -2
- package/src/plugin.js +12 -2
- package/src/prompt-presets.js +429 -0
- package/src/subagent-types.js +147 -0
- package/src/swarm-progress.js +293 -0
- package/src/task-output-viewer.js +135 -0
- package/src/tasks-browser.js +279 -0
- package/src/tools/agent-swarm.js +203 -0
- package/src/tools/agent.js +335 -0
- package/src/tools/ask-user-question.js +206 -0
- package/src/tools/bash.js +46 -28
- package/src/tools/edit.js +117 -6
- package/src/tools/glob.js +8 -1
- package/src/tools/index.js +6 -0
- package/src/tools/tasks.js +140 -126
- package/src/tools/todo.js +29 -2
- package/src/tui.js +2057 -292
- package/src/update.js +107 -0
package/src/tools/edit.js
CHANGED
|
@@ -18,19 +18,76 @@ export const spec = {
|
|
|
18
18
|
type: 'object',
|
|
19
19
|
properties: {
|
|
20
20
|
path: { type: 'string', description: 'Path to the file to edit.' },
|
|
21
|
-
old_string: { type: 'string', description: 'Exact text to replace, including whitespace and newlines.' },
|
|
22
|
-
new_string: { type: 'string', description: 'Replacement text.' },
|
|
21
|
+
old_string: { type: 'string', description: 'Exact text to replace, including whitespace and newlines. Omit when using start_line/end_line.' },
|
|
22
|
+
new_string: { type: 'string', description: 'Replacement text for old_string.' },
|
|
23
23
|
replace_all: { type: 'boolean', default: false, description: 'Replace all occurrences of old_string.' },
|
|
24
|
+
start_line: { type: 'integer', minimum: 1, description: '1-based first line to replace (requires end_line and new_content).' },
|
|
25
|
+
end_line: { type: 'integer', minimum: 1, description: '1-based last line to replace (inclusive; requires start_line).' },
|
|
26
|
+
new_content: { type: 'string', description: 'Replacement text for the line range [start_line, end_line]. Requires start_line and end_line.' },
|
|
24
27
|
},
|
|
25
|
-
|
|
28
|
+
// Either exact-substring mode (old_string+new_string) OR line-range mode.
|
|
29
|
+
anyOf: [
|
|
30
|
+
{ required: ['path', 'old_string', 'new_string'] },
|
|
31
|
+
{ required: ['path', 'start_line', 'end_line', 'new_content'] },
|
|
32
|
+
],
|
|
26
33
|
},
|
|
27
34
|
async execute(args, ctx) {
|
|
28
35
|
let p;
|
|
29
36
|
try { p = resolvePath(args.path, ctx); } catch (e) { return e.message; }
|
|
30
37
|
let content;
|
|
31
38
|
try { content = fs.readFileSync(p, 'utf8'); } catch (e) { return `Error reading ${args.path}: ${e.message}`; }
|
|
32
|
-
const {
|
|
33
|
-
|
|
39
|
+
const { start_line, end_line, new_content } = args;
|
|
40
|
+
|
|
41
|
+
// ---- LINE-RANGE mode ---------------------------------------------------
|
|
42
|
+
// `start_line`..`end_line` replace the lines at those 1-based positions,
|
|
43
|
+
// with NO old_string involved at all. This is the path for "change line 12"
|
|
44
|
+
// where spelling out the exact current text is awkward. It is fully
|
|
45
|
+
// self-contained: validate -> check the Read cache -> splice -> write ->
|
|
46
|
+
// refresh the cache -> return.
|
|
47
|
+
if (start_line != null && end_line != null) {
|
|
48
|
+
const start = Number(start_line) | 0, end = Number(end_line) | 0;
|
|
49
|
+
if (start < 1 || end < start) {
|
|
50
|
+
return `Error: invalid line range ${start_line}-${end_line} (expected 1-based, start <= end).`;
|
|
51
|
+
}
|
|
52
|
+
const eol = content.includes('\r\n') ? '\r\n' : '\n';
|
|
53
|
+
const lines = content.split(/\r\n|\r|\n/);
|
|
54
|
+
if (end > lines.length) {
|
|
55
|
+
return `Error: end_line ${end_line} exceeds file length (${lines.length} lines).`;
|
|
56
|
+
}
|
|
57
|
+
// STALENESS: the range must sit inside a region this turn actually Read and
|
|
58
|
+
// that still matches. Same guarantee as the substring path, checked here
|
|
59
|
+
// directly (no old_string to search for).
|
|
60
|
+
const stale = checkStaleRange(p, lines, start, end, ctx);
|
|
61
|
+
if (stale) return stale;
|
|
62
|
+
|
|
63
|
+
const inserted = normalizeText(new_content).split('\n');
|
|
64
|
+
// The lines being REPLACED, captured before the splice. The TUI renders an
|
|
65
|
+
// Edit's diff from the tool ARGUMENTS, and line-range mode has no
|
|
66
|
+
// `old_string` to diff against — so without reporting them here the edit
|
|
67
|
+
// drew NO diff at all (no +/- rows, no +N/-N counts). Reporting them lets
|
|
68
|
+
// the caller diff `old_content` -> `new_content`.
|
|
69
|
+
const replacedText = lines.slice(start - 1, end).join('\n');
|
|
70
|
+
const out = [...lines.slice(0, start - 1), ...inserted, ...lines.slice(end)];
|
|
71
|
+
const normUpdated = out.join('\n');
|
|
72
|
+
const updated = eol === '\r\n' ? normUpdated.split('\n').join('\r\n') : normUpdated;
|
|
73
|
+
try {
|
|
74
|
+
ensureDir(p);
|
|
75
|
+
fs.writeFileSync(p, updated, 'utf8');
|
|
76
|
+
} catch (e) { return `Error writing ${args.path}: ${e.message}`; }
|
|
77
|
+
refreshReadPool(p, normUpdated, { start, end: start + inserted.length - 1 }, ctx);
|
|
78
|
+
const removed = end - start + 1;
|
|
79
|
+
// Publish the diff for the UI. The Edit line is drawn from these, so a
|
|
80
|
+
// line-range edit shows the same +/- rows and +N/-N counts as a substring
|
|
81
|
+
// edit instead of nothing. `ctx.lastEditDiff` is consumed (and cleared) by
|
|
82
|
+
// the agent loop right after this tool returns.
|
|
83
|
+
if (ctx) ctx.lastEditDiff = { old: replacedText, new: normalizeText(new_content), startLine: start };
|
|
84
|
+
return `Edited ${args.path}: replaced lines ${start}-${end} (${removed} line${removed === 1 ? '' : 's'}) with ${inserted.length} line${inserted.length === 1 ? '' : 's'}.`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
// ---- SUBSTRING mode (old_string -> new_string) --------------------------
|
|
89
|
+
let { old_string, new_string, replace_all = false } = args;
|
|
90
|
+
if (old_string === '' || old_string == null) return `Error: old_string must not be empty.`;
|
|
34
91
|
|
|
35
92
|
// --- Newline normalization (Windows CRLF) ---
|
|
36
93
|
// The Read tool normalizes \r\n -> \n, so an agent builds old_string/new_string
|
|
@@ -96,10 +153,20 @@ export const spec = {
|
|
|
96
153
|
}
|
|
97
154
|
}
|
|
98
155
|
const occurrences = replace_all ? count : 1;
|
|
156
|
+
// Publish the diff for the UI (same contract as the line-range path above).
|
|
157
|
+
if (ctx) {
|
|
158
|
+
const startLine = (() => {
|
|
159
|
+
const at = normContent.split('\n').indexOf(String(normOld).split('\n')[0]);
|
|
160
|
+
return at >= 0 ? at + 1 : 1;
|
|
161
|
+
})();
|
|
162
|
+
ctx.lastEditDiff = { old: normalizeText(old_string), new: normalizeText(new_string), startLine };
|
|
163
|
+
}
|
|
99
164
|
return `Edited ${args.path}: replaced ${occurrences} occurrence(s).`;
|
|
100
165
|
},
|
|
101
166
|
};
|
|
102
167
|
|
|
168
|
+
// Returns an error message when the edit is stale, else null (safe to edit).
|
|
169
|
+
|
|
103
170
|
// Returns an error message when the edit is stale, else null (safe to edit).
|
|
104
171
|
function checkStale(p, normContent, normOld, ctx) {
|
|
105
172
|
if (!ctx || !ctx.readPool) return null; // no Read happened; nothing to verify
|
|
@@ -135,4 +202,48 @@ function checkStale(p, normContent, normOld, ctx) {
|
|
|
135
202
|
// (3) The file changed on disk since it was Read and the edit target is not in
|
|
136
203
|
// an intact Read region. Reject so a stale snippet cannot corrupt the file.
|
|
137
204
|
return `Edit rejected: ${p} changed since it was Read (the edited region is not in an intact Read snapshot). Re-run Read to get the current content, then Edit.`;
|
|
138
|
-
}
|
|
205
|
+
}
|
|
206
|
+
// ---- helpers for the line-range path ---------------------------------------
|
|
207
|
+
|
|
208
|
+
// Same guarantee as checkStale, but for an explicit [start, end] line range:
|
|
209
|
+
// every line in the range must sit inside a region that was Read this turn and
|
|
210
|
+
// whose content still matches. Returns an error string, or null when safe.
|
|
211
|
+
function checkStaleRange(p, curLines, start, end, ctx) {
|
|
212
|
+
if (!ctx || !ctx.readPool) return null; // no Read happened; nothing to verify
|
|
213
|
+
const entry = ctx.readPool.get(p);
|
|
214
|
+
if (!entry) return null; // never Read this turn; allow
|
|
215
|
+
|
|
216
|
+
const normContent = curLines.join('\n');
|
|
217
|
+
const curFileHash = hashStr(normContent);
|
|
218
|
+
|
|
219
|
+
// A region covers the range when it fully contains it AND still hashes the
|
|
220
|
+
// same as when it was Read.
|
|
221
|
+
const covered = entry.regions.some(
|
|
222
|
+
(r) => start >= r.start && end <= r.end && hashLines(curLines, r.start, r.end) === r.hash,
|
|
223
|
+
);
|
|
224
|
+
if (covered) return null;
|
|
225
|
+
|
|
226
|
+
if (curFileHash === entry.fileHash) {
|
|
227
|
+
// No "(read so far: …)" listing: the range is what the model must act on, and
|
|
228
|
+
// echoing the regions it HAS read only invited it to reason about them instead
|
|
229
|
+
// of re-reading the target lines.
|
|
230
|
+
return `Edit rejected: lines ${start}-${end} were not read in ${p}. Read the target lines first (Read with line_offset / n_lines), then Edit.`;
|
|
231
|
+
}
|
|
232
|
+
return `Edit rejected: ${p} changed since it was Read (lines ${start}-${end} are not in an intact Read snapshot). Re-run Read to get the current content, then Edit.`;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Refresh the region cache after a successful edit: drop regions that no longer
|
|
236
|
+
// match the NEW content, and record the freshly-written span. Mirrors the
|
|
237
|
+
// re-arm block used by the substring path.
|
|
238
|
+
function refreshReadPool(p, normUpdated, written, ctx) {
|
|
239
|
+
if (!ctx || !ctx.readPool) return;
|
|
240
|
+
const entry = ctx.readPool.get(p);
|
|
241
|
+
if (!entry) return;
|
|
242
|
+
const newLines = normUpdated.split('\n');
|
|
243
|
+
entry.fileHash = hashStr(normUpdated);
|
|
244
|
+
entry.lines = newLines.length;
|
|
245
|
+
const kept = entry.regions.filter((r) => hashLines(newLines, r.start, r.end) === r.hash);
|
|
246
|
+
kept.push({ start: written.start, end: Math.min(newLines.length, Math.max(written.start, written.end)), hash: hashLines(newLines, written.start, Math.min(newLines.length, Math.max(written.start, written.end))) });
|
|
247
|
+
entry.regions = kept;
|
|
248
|
+
try { const st = fs.statSync(p); entry.size = st.size; entry.mtimeMs = st.mtimeMs; } catch {}
|
|
249
|
+
}
|
package/src/tools/glob.js
CHANGED
|
@@ -22,7 +22,13 @@ export const spec = {
|
|
|
22
22
|
required: ['pattern'],
|
|
23
23
|
},
|
|
24
24
|
async execute(args, ctx) {
|
|
25
|
-
|
|
25
|
+
// `pattern` is required, but the model can still omit it. Return an error
|
|
26
|
+
// string like every other tool instead of throwing — an uncaught throw here
|
|
27
|
+
// aborts the whole turn.
|
|
28
|
+
if (typeof args.pattern !== 'string' || !args.pattern.trim()) {
|
|
29
|
+
return 'Error: `pattern` is required (e.g. "src/**/*.ts").';
|
|
30
|
+
}
|
|
31
|
+
const fromDir = typeof args.path === 'string' && args.path ? args.path : '.';
|
|
26
32
|
const p = normalizeInput(fromDir);
|
|
27
33
|
let base;
|
|
28
34
|
try { base = path.resolve(ctx.cwd || ctx.workspace, p); } catch (e) { return `Error: ${e.message}`; }
|
|
@@ -32,6 +38,7 @@ export const spec = {
|
|
|
32
38
|
const alts = expandBraces(args.pattern);
|
|
33
39
|
const re = new RegExp('^(?:' + alts.map((a) => globRegex(a).source.slice(1, -1)).join('|') + ')$');
|
|
34
40
|
|
|
41
|
+
|
|
35
42
|
const files = walkFiles(base, { includeIgnored: !!args.include_ignored, includeDirs: false });
|
|
36
43
|
const matches = [];
|
|
37
44
|
for (const abs of files) {
|
package/src/tools/index.js
CHANGED
|
@@ -12,6 +12,9 @@ import * as fetchUrlMod from './fetch-url.js';
|
|
|
12
12
|
import * as webSearchMod from './web-search.js';
|
|
13
13
|
import * as readMediaFileMod from './read-media-file.js';
|
|
14
14
|
import * as fileLinesMod from './file-lines.js';
|
|
15
|
+
import * as askUserQuestionMod from './ask-user-question.js';
|
|
16
|
+
import { spec as agentSpec } from './agent.js';
|
|
17
|
+
import { spec as agentSwarmSpec } from './agent-swarm.js';
|
|
15
18
|
import { TaskListSpec, TaskOutputSpec, TaskStopSpec, TaskWaitSpec } from './tasks.js';
|
|
16
19
|
import { pluginTools } from '../plugin.js';
|
|
17
20
|
|
|
@@ -28,6 +31,9 @@ const builtinTools = [
|
|
|
28
31
|
webSearchMod.spec,
|
|
29
32
|
readMediaFileMod.spec,
|
|
30
33
|
fileLinesMod.spec,
|
|
34
|
+
askUserQuestionMod.spec,
|
|
35
|
+
agentSpec,
|
|
36
|
+
agentSwarmSpec,
|
|
31
37
|
TaskListSpec,
|
|
32
38
|
TaskOutputSpec,
|
|
33
39
|
TaskStopSpec,
|
package/src/tools/tasks.js
CHANGED
|
@@ -1,126 +1,140 @@
|
|
|
1
|
-
// TaskList / TaskOutput / TaskStop — manage background
|
|
2
|
-
// run_in_background.
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
},
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
}
|
|
65
|
-
t
|
|
66
|
-
t.
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
},
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
if (
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
};
|
|
1
|
+
// TaskList / TaskOutput / TaskStop / TaskWait — manage background tasks spawned
|
|
2
|
+
// by Bash (run_in_background) and Agent (run_in_background). The task records
|
|
3
|
+
// live in the shared registry (src/agent-task.js), so the CLI tools and the
|
|
4
|
+
// /tasks panel read exactly the same data.
|
|
5
|
+
|
|
6
|
+
import cp from 'node:child_process';
|
|
7
|
+
import { isTerminal, settleTask, STATUS_LABEL } from '../agent-task.js';
|
|
8
|
+
|
|
9
|
+
function taskLine(t) {
|
|
10
|
+
const extra = t.stopReason ? ` (${t.stopReason})` : '';
|
|
11
|
+
const label = STATUS_LABEL[t.status] || t.status;
|
|
12
|
+
const what = t.kind === 'process' ? (t.description || t.command) : t.description;
|
|
13
|
+
return `#${t.taskId} [${label}${extra}] pid=${t.pid || '-'} ${what}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const TaskListSpec = {
|
|
17
|
+
name: 'TaskList',
|
|
18
|
+
description: 'List background tasks (from Bash or Agent run_in_background). Reports task id, status, description.',
|
|
19
|
+
parameters: { type: 'object', properties: {}, required: [] },
|
|
20
|
+
async execute(_args, ctx) {
|
|
21
|
+
const tasks = Object.values(ctx.tasks || {}).sort((a, b) => b.startedAt - a.startedAt);
|
|
22
|
+
if (tasks.length === 0) return 'No background tasks.';
|
|
23
|
+
return tasks.map(taskLine).join('\n');
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const TaskOutputSpec = {
|
|
28
|
+
name: 'TaskOutput',
|
|
29
|
+
description: 'Get the output of a background task by task_id. head_limit caps the lines returned.',
|
|
30
|
+
parameters: {
|
|
31
|
+
type: 'object',
|
|
32
|
+
properties: {
|
|
33
|
+
task_id: { type: 'string', description: 'Task id returned when the task was started.' },
|
|
34
|
+
head_limit: { type: 'integer', minimum: 0, description: 'Max output lines returned.' },
|
|
35
|
+
},
|
|
36
|
+
required: ['task_id'],
|
|
37
|
+
},
|
|
38
|
+
async execute(args, ctx) {
|
|
39
|
+
const t = (ctx.tasks || {})[args.task_id];
|
|
40
|
+
if (!t) return `No such task: ${args.task_id}`;
|
|
41
|
+
const text = t.output || '';
|
|
42
|
+
const lines = text ? text.split('\n').filter((l) => l.length > 0) : [];
|
|
43
|
+
const capped = args.head_limit ? lines.slice(0, args.head_limit) : lines;
|
|
44
|
+
const tail = lines.length > capped.length;
|
|
45
|
+
const label = STATUS_LABEL[t.status] || t.status;
|
|
46
|
+
let out = `#${t.taskId} [${label}] (pid ${t.pid || '-'})\n${capped.join('\n')}`;
|
|
47
|
+
if (tail) out += `\n... [${lines.length - capped.length} more lines; use head_limit]`;
|
|
48
|
+
return out;
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export const TaskStopSpec = {
|
|
53
|
+
name: 'TaskStop',
|
|
54
|
+
description: 'Stop a running background task by task_id. Use only when the task must be cancelled.',
|
|
55
|
+
parameters: {
|
|
56
|
+
type: 'object',
|
|
57
|
+
properties: {
|
|
58
|
+
task_id: { type: 'string', description: 'Background task id to stop.' },
|
|
59
|
+
reason: { type: 'string', description: 'Short reason recorded on stop.' },
|
|
60
|
+
},
|
|
61
|
+
required: ['task_id'],
|
|
62
|
+
},
|
|
63
|
+
async execute(args, ctx) {
|
|
64
|
+
const t = (ctx.tasks || {})[args.task_id];
|
|
65
|
+
if (!t) return `No such task: ${args.task_id}`;
|
|
66
|
+
if (isTerminal(t.status)) return `Task ${t.taskId} already ${STATUS_LABEL[t.status] || t.status}.`;
|
|
67
|
+
const reason = args.reason || 'Stopped by TaskStop';
|
|
68
|
+
try {
|
|
69
|
+
// An agent task aborts through its controller; a process is killed.
|
|
70
|
+
if (t.kind === 'agent' && typeof t._abort === 'function') {
|
|
71
|
+
t._abort();
|
|
72
|
+
} else if (t.pid) {
|
|
73
|
+
if (process.platform === 'win32') {
|
|
74
|
+
try { cp.spawnSync('taskkill', ['/pid', String(t.pid), '/t', '/f'], { stdio: 'ignore' }); } catch {}
|
|
75
|
+
} else {
|
|
76
|
+
try { process.kill(t.pid, 'SIGTERM'); } catch {}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
} catch (e) {
|
|
80
|
+
return `Failed to stop ${t.taskId}: ${e.message}`;
|
|
81
|
+
}
|
|
82
|
+
// 'killed' — a deliberate stop, distinct from a failure (kimi's vocabulary).
|
|
83
|
+
settleTask(t, 'killed', { stopReason: reason });
|
|
84
|
+
return `Stopped task ${t.taskId}. reason: ${reason}`;
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// Wait for background tasks to finish. A timeout is not an error; the result
|
|
89
|
+
// lists the tasks still running so the caller can wait again.
|
|
90
|
+
export const TaskWaitSpec = {
|
|
91
|
+
name: 'TaskWait',
|
|
92
|
+
description: 'Wait for background tasks to finish, timeout in seconds (1-600). With task_id, waits for that task; without it, returns when any running task finishes. A timeout is not an error; still-running tasks are listed, so you may call it again.',
|
|
93
|
+
parameters: {
|
|
94
|
+
type: 'object',
|
|
95
|
+
properties: {
|
|
96
|
+
timeout: { type: 'integer', minimum: 1, maximum: 600, description: 'Max seconds to wait (1-600).' },
|
|
97
|
+
task_id: { type: 'string', description: 'Task id to wait for; omit to wait for any running task.' },
|
|
98
|
+
},
|
|
99
|
+
required: ['timeout'],
|
|
100
|
+
},
|
|
101
|
+
async execute(args, ctx) {
|
|
102
|
+
const tasks = ctx.tasks || {};
|
|
103
|
+
const target = args.task_id ? tasks[args.task_id] : null;
|
|
104
|
+
if (args.task_id && !target) return `No such task: ${args.task_id}`;
|
|
105
|
+
const secs = Math.max(1, Math.min(600, Number(args.timeout) || 1));
|
|
106
|
+
const deadline = Date.now() + secs * 1000;
|
|
107
|
+
const signal = ctx && ctx.signal;
|
|
108
|
+
|
|
109
|
+
const isDone = (t) => t && isTerminal(t.status);
|
|
110
|
+
const anyRunning = () => Object.values(tasks).some((t) => t.status === 'running');
|
|
111
|
+
|
|
112
|
+
if (args.task_id) {
|
|
113
|
+
if (isDone(target)) return `Task ${target.taskId} already ${STATUS_LABEL[target.status] || target.status}${target.exitCode != null ? ` (exit ${target.exitCode})` : ''}.`;
|
|
114
|
+
} else if (!anyRunning()) {
|
|
115
|
+
return 'No background tasks are running.';
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
await new Promise((resolve) => {
|
|
119
|
+
const tick = setInterval(() => {
|
|
120
|
+
const done = args.task_id ? isDone(target) : !anyRunning();
|
|
121
|
+
if (done || Date.now() >= deadline || (signal && signal.aborted)) {
|
|
122
|
+
clearInterval(tick);
|
|
123
|
+
resolve();
|
|
124
|
+
}
|
|
125
|
+
}, 100);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
if (signal && signal.aborted) return 'Interrupted by user';
|
|
129
|
+
const still = Object.values(tasks).filter((t) => t.status === 'running');
|
|
130
|
+
if (args.task_id) {
|
|
131
|
+
if (isDone(target)) {
|
|
132
|
+
return `Task ${target.taskId} finished: ${STATUS_LABEL[target.status] || target.status}${target.exitCode != null ? ` (exit ${target.exitCode})` : ''}${target.stopReason ? ` — ${target.stopReason}` : ''}.`;
|
|
133
|
+
}
|
|
134
|
+
return `Task ${target.taskId} still running after ${secs}s.`;
|
|
135
|
+
}
|
|
136
|
+
if (!still.length) return 'All background tasks have finished.';
|
|
137
|
+
const list = still.map((t) => `#${t.taskId} ${t.description || t.command || ''}`).join('; ');
|
|
138
|
+
return `Timeout after ${secs}s; still running: ${list}`;
|
|
139
|
+
},
|
|
140
|
+
};
|
package/src/tools/todo.js
CHANGED
|
@@ -3,7 +3,21 @@
|
|
|
3
3
|
|
|
4
4
|
export const spec = {
|
|
5
5
|
name: 'TodoList',
|
|
6
|
-
description:
|
|
6
|
+
description: `Track multi-step work. Pass the COMPLETE list; it replaces the current one. Items: title + status (pending | in_progress | done).
|
|
7
|
+
|
|
8
|
+
When to use it:
|
|
9
|
+
- Non-trivial work with several ordered steps, or a request that asks for more than one thing.
|
|
10
|
+
- NOT for a straightforward task you can finish in a step or two, and never as a single-item list.
|
|
11
|
+
|
|
12
|
+
Maintaining the list:
|
|
13
|
+
- Keep exactly ONE item in_progress at a time until everything is done.
|
|
14
|
+
- Never move an item straight from pending to done: set it to in_progress first, so the list reflects
|
|
15
|
+
what is actually happening. Do not tick off several items in one update after the fact.
|
|
16
|
+
- Step titles are short (5-7 words), one line each.
|
|
17
|
+
- Update the list as you go, before starting the next step, not in a batch at the end.
|
|
18
|
+
- If your understanding changes, replace the whole list with the corrected one and say why.
|
|
19
|
+
- Finish with every item done, or explicitly say which you abandoned and why. Do not leave the list stale.
|
|
20
|
+
- Do not repeat the list back to the user after updating it — the UI already shows it.`,
|
|
7
21
|
parameters: {
|
|
8
22
|
type: 'object',
|
|
9
23
|
properties: {
|
|
@@ -23,7 +37,20 @@ export const spec = {
|
|
|
23
37
|
required: ['todos'],
|
|
24
38
|
},
|
|
25
39
|
async execute(args, ctx) {
|
|
26
|
-
|
|
40
|
+
// `todos` is required, but a missing or mistyped value must not throw: an
|
|
41
|
+
// uncaught error here aborts the whole turn. Return a message the model can
|
|
42
|
+
// act on, like every other tool.
|
|
43
|
+
if (!Array.isArray(args.todos)) {
|
|
44
|
+
return 'Error: `todos` must be an array of { title, status }.';
|
|
45
|
+
}
|
|
46
|
+
const VALID = ['pending', 'in_progress', 'done'];
|
|
47
|
+
const badStatus = args.todos.find((t) => t && t.status != null && !VALID.includes(t.status));
|
|
48
|
+
if (badStatus) {
|
|
49
|
+
return `Error: invalid status ${JSON.stringify(badStatus.status)}; use one of ${VALID.join(' | ')}.`;
|
|
50
|
+
}
|
|
51
|
+
const empty = args.todos.find((t) => !t || typeof t.title !== 'string' || !t.title.trim());
|
|
52
|
+
if (empty) return 'Error: every todo needs a non-empty `title`.';
|
|
53
|
+
ctx.todoState = args.todos.map((t) => ({ title: String(t.title).trim(), status: t.status || 'pending' }));
|
|
27
54
|
return render(ctx.todoState);
|
|
28
55
|
},
|
|
29
56
|
};
|