@hncode/hncode 0.2.0 → 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/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
- required: ['path', 'old_string', 'new_string'],
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 { old_string, new_string, replace_all = false } = args;
33
- if (old_string === '') return `Error: old_string must not be empty.`;
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
- const fromDir = args.path ? args.path : '.';
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) {
@@ -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,
@@ -1,126 +1,140 @@
1
- // TaskList / TaskOutput / TaskStop — manage background Bash tasks spawned with
2
- // run_in_background.
3
-
4
- import cp from 'node:child_process';
5
-
6
- export const TaskListSpec = {
7
- name: 'TaskList',
8
- description: 'List background tasks (from Bash run_in_background). Reports id, status, description.',
9
- parameters: { type: 'object', properties: {}, required: [] },
10
- async execute(_args, ctx) {
11
- const tasks = Object.values(ctx.tasks || {}).sort((a, b) => b.start - a.start);
12
- if (tasks.length === 0) return 'No background tasks.';
13
- return tasks.map((t) => `#${t.id} [${t.status}] pid=${t.pid} ${t.description || t.command}`).join('\n');
14
- },
15
- };
16
-
17
- export const TaskOutputSpec = {
18
- name: 'TaskOutput',
19
- description: 'Get the output of a background task (Bash run_in_background) by task_id. head_limit caps the lines returned.',
20
- parameters: {
21
- type: 'object',
22
- properties: {
23
- task_id: { type: 'string', description: 'Task id returned when the task was started.' },
24
- head_limit: { type: 'integer', minimum: 0, description: 'Max output lines returned.' },
25
- },
26
- required: ['task_id'],
27
- },
28
- async execute(args, ctx) {
29
- const t = (ctx.tasks || {})[args.task_id];
30
- if (!t) return `No such task: ${args.task_id}`;
31
- const lines = t.output ? t.output.split('\n').filter((l) => l.length > 0) : [];
32
- const capped = args.head_limit ? lines.slice(0, args.head_limit) : lines;
33
- const tail = lines.length > (capped.length);
34
- let out = `#${t.id} [${t.status}] (pid ${t.pid})\n${capped.join('\n')}`;
35
- if (tail) out += `\n... [${lines.length - capped.length} more lines; use head_limit]`;
36
- return out;
37
- },
38
- };
39
-
40
- export const TaskStopSpec = {
41
- name: 'TaskStop',
42
- description: 'Stop a running background task (Bash run_in_background) by task_id. Use only when the task must be cancelled.',
43
- parameters: {
44
- type: 'object',
45
- properties: {
46
- task_id: { type: 'string', description: 'Background task id to stop.' },
47
- reason: { type: 'string', description: 'Short reason recorded on stop.' },
48
- },
49
- required: ['task_id'],
50
- },
51
- async execute(args, ctx) {
52
- const t = (ctx.tasks || {})[args.task_id];
53
- if (!t) return `No such task: ${args.task_id}`;
54
- if (t.status !== 'running') return `Task ${t.id} is not running (status: ${t.status}).`;
55
- const reason = args.reason || 'Stopped by TaskStop';
56
- try {
57
- if (process.platform === 'win32' && t.pid) {
58
- try { cp.spawnSync('taskkill', ['/pid', String(t.pid), '/t', '/f'], { stdio: 'ignore' }); } catch {}
59
- } else if (t.pid) {
60
- try { process.kill(t.pid, 'SIGTERM'); } catch {}
61
- }
62
- } catch (e) {
63
- return `Failed to stop ${t.id}: ${e.message}`;
64
- }
65
- t.status = 'stopped';
66
- t.stopReason = reason;
67
- t.end = Date.now();
68
- return `Stopped task ${t.id}. reason: ${reason}`;
69
- },
70
- };
71
-
72
- // WaitFor — block until a background task finishes (or the timeout elapses).
73
- // Mirrors kimi-code's task-wait: a timeout is not an error; the result lists
74
- // the tasks still running so the caller can wait again.
75
- export const TaskWaitSpec = {
76
- name: 'TaskWait',
77
- description: 'Wait for background tasks (Bash run_in_background) 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.',
78
- parameters: {
79
- type: 'object',
80
- properties: {
81
- timeout: { type: 'integer', minimum: 1, maximum: 600, description: 'Max seconds to wait (1-600).' },
82
- task_id: { type: 'string', description: 'Task id to wait for; omit to wait for any running task.' },
83
- },
84
- required: ['timeout'],
85
- },
86
- async execute(args, ctx) {
87
- const tasks = ctx.tasks || {};
88
- const target = args.task_id ? tasks[args.task_id] : null;
89
- if (args.task_id && !target) return `No such task: ${args.task_id}`;
90
- const secs = Math.max(1, Math.min(600, Number(args.timeout) || 1));
91
- const deadline = Date.now() + secs * 1000;
92
- const signal = ctx && ctx.signal;
93
-
94
- const isDone = (t) => t && t.status !== 'running';
95
- const anyRunning = () => Object.values(tasks).some((t) => t.status === 'running');
96
-
97
- // Nothing to wait for at call time.
98
- if (args.task_id) {
99
- if (isDone(target)) return `Task ${target.id} already ${target.status}${target.code !== undefined ? ` (exit ${target.code})` : ''}.`;
100
- } else if (!anyRunning()) {
101
- return 'No background tasks are running.';
102
- }
103
-
104
- await new Promise((resolve) => {
105
- const tick = setInterval(() => {
106
- const done = args.task_id ? isDone(target) : !anyRunning();
107
- if (done || Date.now() >= deadline || (signal && signal.aborted)) {
108
- clearInterval(tick);
109
- resolve();
110
- }
111
- }, 100);
112
- });
113
-
114
- if (signal && signal.aborted) return 'Interrupted by user';
115
- const still = Object.values(tasks).filter((t) => t.status === 'running');
116
- if (args.task_id) {
117
- if (isDone(target)) {
118
- return `Task ${target.id} finished: ${target.status}${target.code !== undefined ? ` (exit ${target.code})` : ''}${target.stopReason ? ` — ${target.stopReason}` : ''}.`;
119
- }
120
- return `Task ${target.id} still running after ${secs}s.`;
121
- }
122
- if (!still.length) return 'All background tasks have finished.';
123
- const list = still.map((t) => `#${t.id} ${t.description || t.command}`).join('; ');
124
- return `Timeout after ${secs}s; still running: ${list}`;
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: 'Track multi-step work. Pass the COMPLETE list; it replaces the current one. Items: title + status (pending | in_progress | done).',
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
- ctx.todoState = args.todos.map((t) => ({ title: String(t.title), status: t.status || 'pending' }));
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
  };