codemini-cli 0.3.1 → 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/src/core/tools.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import fs from 'node:fs/promises';
2
+ import fsSync from 'node:fs';
2
3
  import path from 'node:path';
3
4
  import { spawn } from 'node:child_process';
4
5
  import net from 'node:net';
@@ -17,19 +18,61 @@ import { initializeProjectIndex, queryProjectIndex, refreshIndexedFile } from '.
17
18
  import { checkReadDedup } from './agent-loop.js';
18
19
  import { TOOL_SKIP_DIRS as SKIP_DIRS, TEXT_EXTENSIONS, CODE_WRITE_GUARD_EXTENSIONS, LANGUAGE_FILE_TYPES } from './constants.js';
19
20
  import { sha256Prefixed as sha256, sha1 } from './crypto-utils.js';
20
- const SERVICE_RECENT_LOG_LIMIT = 80;
21
- const SERVICE_STARTUP_POLL_MS = 150;
22
- const serviceRegistry = new Map();
23
- let serviceCounter = 0;
24
- let serviceLogCursorCounter = 0;
21
+ import { forgetMemory, listMemories, rememberMemory, searchMemories } from './memory-store.js';
22
+ import { normalizeTodos } from './todo-state.js';
23
+ const BACKGROUND_TASK_RECENT_OUTPUT_LIMIT = 80;
24
+ const BACKGROUND_TASK_POLL_MS = 150;
25
+ const backgroundTaskRegistry = new Map();
26
+ let backgroundTaskCounter = 0;
27
+ let backgroundTaskLogCursorCounter = 0;
28
+
29
+ function realpathIfExists(targetPath) {
30
+ try {
31
+ return fsSync.realpathSync.native(targetPath);
32
+ } catch (error) {
33
+ if (error?.code === 'ENOENT') return null;
34
+ throw error;
35
+ }
36
+ }
37
+
38
+ function isWithinResolvedRoot(resolvedRoot, candidatePath) {
39
+ const relative = path.relative(resolvedRoot, candidatePath);
40
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
41
+ }
25
42
 
26
43
  function resolveInWorkspace(root, targetPath = '.') {
27
44
  const absRoot = path.resolve(root);
45
+ const realRoot = fsSync.realpathSync.native(absRoot);
28
46
  const absTarget = path.resolve(absRoot, targetPath);
29
- if (!absTarget.startsWith(absRoot)) {
47
+ const realTarget = realpathIfExists(absTarget);
48
+ if (realTarget) {
49
+ if (!isWithinResolvedRoot(realRoot, realTarget)) {
50
+ throw new Error(`Path escapes workspace: ${targetPath}`);
51
+ }
52
+ return realTarget;
53
+ }
54
+
55
+ let probe = path.dirname(absTarget);
56
+ while (!realpathIfExists(probe)) {
57
+ const parent = path.dirname(probe);
58
+ if (parent === probe) break;
59
+ probe = parent;
60
+ }
61
+
62
+ const resolvedProbe = realpathIfExists(probe);
63
+ if (!resolvedProbe) {
30
64
  throw new Error(`Path escapes workspace: ${targetPath}`);
31
65
  }
32
- return absTarget;
66
+
67
+ const resolvedTarget = path.join(resolvedProbe, path.relative(probe, absTarget));
68
+ if (!isWithinResolvedRoot(realRoot, resolvedTarget)) {
69
+ throw new Error(`Path escapes workspace: ${targetPath}`);
70
+ }
71
+ return resolvedTarget;
72
+ }
73
+
74
+ function getBackgroundTasksDir(root) {
75
+ return path.join(resolveInWorkspace(root, '.codemini'), 'tasks');
33
76
  }
34
77
 
35
78
  function toWorkspaceRelative(root, absPath) {
@@ -232,56 +275,68 @@ function normalizeFileTypes(args = {}) {
232
275
  return [...new Set(merged)];
233
276
  }
234
277
 
278
+ async function mapLimit(items, limit, worker) {
279
+ const list = Array.isArray(items) ? items : [];
280
+ if (list.length === 0) return [];
281
+ const maxConcurrent = Math.max(1, Math.min(Number(limit) || 1, list.length));
282
+ const results = new Array(list.length);
283
+ let nextIndex = 0;
284
+
285
+ async function runNext() {
286
+ while (nextIndex < list.length) {
287
+ const currentIndex = nextIndex;
288
+ nextIndex += 1;
289
+ results[currentIndex] = await worker(list[currentIndex], currentIndex);
290
+ }
291
+ }
292
+
293
+ await Promise.all(Array.from({ length: maxConcurrent }, () => runNext()));
294
+ return results;
295
+ }
296
+
297
+ const WALKER_CONCURRENCY = 8;
298
+
235
299
  async function walkTextFiles(root, startPath = '.', fileTypes = []) {
236
300
  const abs = resolveInWorkspace(root, startPath);
237
- const out = [];
238
301
  const allowedExts = new Set((Array.isArray(fileTypes) ? fileTypes : []).map((item) => `.${String(item || '').replace(/^\./, '')}`));
239
302
 
240
303
  async function visit(current) {
241
304
  const stat = await fs.stat(current);
242
305
  if (stat.isDirectory()) {
243
306
  const name = path.basename(current);
244
- if (SKIP_DIRS.has(name)) return;
307
+ if (SKIP_DIRS.has(name)) return [];
245
308
  const entries = await fs.readdir(current);
246
- for (const entry of entries) {
247
- await visit(path.join(current, entry));
248
- }
249
- return;
309
+ const nested = await mapLimit(entries, WALKER_CONCURRENCY, async (entry) => visit(path.join(current, entry)));
310
+ return nested.flat();
250
311
  }
251
- if (!detectTextFile(current)) return;
252
- if (allowedExts.size > 0 && !allowedExts.has(path.extname(current).toLowerCase())) return;
253
- out.push(current);
312
+ if (!detectTextFile(current)) return [];
313
+ if (allowedExts.size > 0 && !allowedExts.has(path.extname(current).toLowerCase())) return [];
314
+ return [current];
254
315
  }
255
316
 
256
- await visit(abs);
257
- return out;
317
+ return visit(abs);
258
318
  }
259
319
 
260
320
  async function walkWorkspaceEntries(root, startPath = '.', { includeHidden = false } = {}) {
261
321
  const abs = resolveInWorkspace(root, startPath);
262
- const out = [];
263
322
 
264
323
  async function visit(current) {
265
324
  const stat = await fs.stat(current);
266
325
  const relative = toWorkspaceRelative(root, current) || '.';
267
326
  const name = path.basename(current);
268
327
 
269
- if (!includeHidden && name.startsWith('.') && relative !== '.') return;
328
+ if (!includeHidden && name.startsWith('.') && relative !== '.') return [];
270
329
  if (stat.isDirectory()) {
271
- if (SKIP_DIRS.has(name) && relative !== '.') return;
272
- out.push({ path: relative, name, type: 'dir' });
330
+ if (SKIP_DIRS.has(name) && relative !== '.') return [];
273
331
  const entries = await fs.readdir(current);
274
- for (const entry of entries) {
275
- await visit(path.join(current, entry));
276
- }
277
- return;
332
+ const nested = await mapLimit(entries, WALKER_CONCURRENCY, async (entry) => visit(path.join(current, entry)));
333
+ return [{ path: relative, name, type: 'dir' }, ...nested.flat()];
278
334
  }
279
335
 
280
- out.push({ path: relative, name, type: 'file' });
336
+ return [{ path: relative, name, type: 'file' }];
281
337
  }
282
338
 
283
- await visit(abs);
284
- return out;
339
+ return visit(abs);
285
340
  }
286
341
 
287
342
  function globToRegex(pattern) {
@@ -839,18 +894,6 @@ async function runCommand(root, config, args) {
839
894
  if (!command.trim()) {
840
895
  throw new Error('run requires command');
841
896
  }
842
- if (isLikelyLongRunningCommand(command)) {
843
- const intent = classifyCommandIntent(command);
844
- const labelMap = {
845
- 'frontend-service': 'frontend service',
846
- 'backend-service': 'backend service',
847
- 'database-service': 'database service',
848
- 'docker-service': 'Docker service',
849
- service: 'long-running service'
850
- };
851
- const label = labelMap[intent.kind] || 'long-running service';
852
- throw new Error(`Command looks like a ${label}. Use start_service instead of run.`);
853
- }
854
897
  if (
855
898
  !config.policy.allow_dangerous_commands &&
856
899
  isDangerousCommand(command, config.policy.blocked_command_patterns)
@@ -865,6 +908,16 @@ async function runCommand(root, config, args) {
865
908
  );
866
909
  }
867
910
 
911
+ const shouldBackground =
912
+ args?.run_in_background === true ||
913
+ args?.runInBackground === true ||
914
+ args?.background === true ||
915
+ isLikelyLongRunningCommand(command);
916
+
917
+ if (shouldBackground) {
918
+ return startBackgroundTask(root, config, args);
919
+ }
920
+
868
921
  const result = await runShellCommand({
869
922
  command,
870
923
  cwd: root,
@@ -874,9 +927,9 @@ async function runCommand(root, config, args) {
874
927
  return { ...result, command };
875
928
  }
876
929
 
877
- function nextServiceId() {
878
- serviceCounter += 1;
879
- return `svc_${String(serviceCounter).padStart(3, '0')}`;
930
+ function nextBackgroundTaskId() {
931
+ backgroundTaskCounter += 1;
932
+ return `task_${String(backgroundTaskCounter).padStart(3, '0')}`;
880
933
  }
881
934
 
882
935
  function normalizeSuccessMatchers(items = []) {
@@ -884,39 +937,39 @@ function normalizeSuccessMatchers(items = []) {
884
937
  return items.map((item) => String(item || '').trim()).filter(Boolean);
885
938
  }
886
939
 
887
- function shellCommandForService(command, shellSpec) {
940
+ function shellCommandForBackgroundTask(command, shellSpec) {
888
941
  return process.platform !== 'win32' && /(?:^|\/)bash(?:\.exe)?$/i.test(shellSpec.command)
889
942
  ? `exec ${command}`
890
943
  : command;
891
944
  }
892
945
 
893
- function appendRecentLogs(service, chunk) {
946
+ function appendRecentOutput(task, chunk) {
894
947
  const lines = String(chunk || '')
895
948
  .split(/\r?\n/)
896
949
  .map((line) => trimLinePreview(line, 220))
897
950
  .filter(Boolean);
898
951
  if (lines.length === 0) return;
899
952
  for (const line of lines) {
900
- serviceLogCursorCounter += 1;
901
- service.recentLogs.push({ cursor: serviceLogCursorCounter, line });
953
+ backgroundTaskLogCursorCounter += 1;
954
+ task.recentLogs.push({ cursor: backgroundTaskLogCursorCounter, line });
902
955
  }
903
- if (service.recentLogs.length > SERVICE_RECENT_LOG_LIMIT) {
904
- service.recentLogs.splice(0, service.recentLogs.length - SERVICE_RECENT_LOG_LIMIT);
956
+ if (task.recentLogs.length > BACKGROUND_TASK_RECENT_OUTPUT_LIMIT) {
957
+ task.recentLogs.splice(0, task.recentLogs.length - BACKGROUND_TASK_RECENT_OUTPUT_LIMIT);
905
958
  }
906
959
  }
907
960
 
908
- function matchesServiceSuccess(service, text) {
961
+ function matchesTaskStartupSuccess(task, text) {
909
962
  const value = String(text || '');
910
963
  if (!value) return false;
911
964
  if (hasReadyOutput(value)) return true;
912
- return service.successMatchers.some((matcher) => value.toLowerCase().includes(matcher.toLowerCase()));
965
+ return task.successMatchers.some((matcher) => value.toLowerCase().includes(matcher.toLowerCase()));
913
966
  }
914
967
 
915
- function markServiceReady(service, source = 'output') {
916
- if (service.startupConfirmed) return;
917
- service.startupConfirmed = true;
918
- service.startupSource = source;
919
- service.status = 'running';
968
+ function markTaskReady(task, source = 'output') {
969
+ if (task.startupConfirmed) return;
970
+ task.startupConfirmed = true;
971
+ task.startupSource = source;
972
+ task.status = 'running';
920
973
  }
921
974
 
922
975
  function serviceUrlForPort(port) {
@@ -935,34 +988,38 @@ function normalizeHttpProbe(value) {
935
988
  };
936
989
  }
937
990
 
938
- function snapshotService(service, tail = 12) {
939
- const recentLogs = Array.isArray(service.recentLogs)
940
- ? service.recentLogs.slice(-Math.max(1, tail)).map((item) => item.line)
991
+ function snapshotBackgroundTask(task, tail = 12) {
992
+ const recentOutput = Array.isArray(task.recentLogs)
993
+ ? task.recentLogs.slice(-Math.max(1, tail)).map((item) => item.line)
941
994
  : [];
942
995
  const latestCursor =
943
- Array.isArray(service.recentLogs) && service.recentLogs.length > 0
944
- ? service.recentLogs[service.recentLogs.length - 1].cursor
996
+ Array.isArray(task.recentLogs) && task.recentLogs.length > 0
997
+ ? task.recentLogs[task.recentLogs.length - 1].cursor
945
998
  : 0;
946
999
  return {
947
- task_id: service.taskId,
948
- pid: service.child?.pid || null,
949
- command: service.command,
950
- cwd: service.cwd,
951
- status: service.status,
952
- startup_confirmed: service.startupConfirmed,
953
- startup_source: service.startupSource || '',
954
- http_probe: service.httpProbe || undefined,
955
- url: serviceUrlForPort(service.portProbe) || undefined,
956
- recent_logs: recentLogs,
1000
+ task_id: task.taskId,
1001
+ pid: task.child?.pid || null,
1002
+ command: task.command,
1003
+ cwd: task.cwd,
1004
+ status: task.status,
1005
+ background: true,
1006
+ kind: task.intentKind,
1007
+ startup_confirmed: task.startupConfirmed,
1008
+ startup_source: task.startupSource || '',
1009
+ http_probe: task.httpProbe || undefined,
1010
+ url: serviceUrlForPort(task.portProbe) || undefined,
1011
+ output_file: task.outputFile,
1012
+ recent_output: recentOutput,
1013
+ recent_logs: recentOutput,
957
1014
  log_cursor: latestCursor,
958
- exit_code: service.exitCode ?? undefined,
959
- signal: service.signal ?? undefined,
960
- duration_ms: Date.now() - service.startedAt
1015
+ exit_code: task.exitCode ?? undefined,
1016
+ signal: task.signal ?? undefined,
1017
+ duration_ms: Date.now() - task.startedAt
961
1018
  };
962
1019
  }
963
1020
 
964
- function listServiceSnapshots() {
965
- return Array.from(serviceRegistry.values()).map((service) => snapshotService(service, 4));
1021
+ function listBackgroundTaskSnapshots() {
1022
+ return Array.from(backgroundTaskRegistry.values()).map((task) => snapshotBackgroundTask(task, 4));
966
1023
  }
967
1024
 
968
1025
  function probePortOnce(port, host = '127.0.0.1', timeoutMs = 250) {
@@ -1000,9 +1057,16 @@ async function probeHttpOnce(httpProbe, timeoutMs = 400) {
1000
1057
  }
1001
1058
  }
1002
1059
 
1003
- async function startService(root, config, args) {
1060
+ function queueBackgroundTaskOutputWrite(task, chunk) {
1061
+ if (!task?.outputFileAbs) return;
1062
+ task.outputWrite = (task.outputWrite || Promise.resolve())
1063
+ .then(() => fs.appendFile(task.outputFileAbs, String(chunk || ''), 'utf8'))
1064
+ .catch(() => {});
1065
+ }
1066
+
1067
+ async function startBackgroundTask(root, config, args) {
1004
1068
  const command = String(args?.command || args?.cmd || '').trim();
1005
- if (!command) throw new Error('start_service requires command');
1069
+ if (!command) throw new Error('run requires command');
1006
1070
  if (
1007
1071
  !config.policy.allow_dangerous_commands &&
1008
1072
  isDangerousCommand(command, config.policy.blocked_command_patterns)
@@ -1017,54 +1081,65 @@ async function startService(root, config, args) {
1017
1081
  }
1018
1082
 
1019
1083
  const shellSpec = resolveShell(config.shell.default);
1020
- const taskId = nextServiceId();
1084
+ const taskId = nextBackgroundTaskId();
1021
1085
  const startupTimeoutMs = Math.max(250, Number(args?.startup_timeout_ms || args?.startupTimeoutMs || 20000));
1022
1086
  const successMatchers = normalizeSuccessMatchers(args?.success_matchers || args?.successMatchers);
1023
1087
  const portProbe = Number(args?.port_probe || args?.portProbe || 0) || 0;
1024
1088
  const httpProbe = normalizeHttpProbe(args?.http_probe || args?.httpProbe);
1025
- const service = {
1089
+ const outputDir = getBackgroundTasksDir(root);
1090
+ await fs.mkdir(outputDir, { recursive: true });
1091
+ const outputFileAbs = path.join(outputDir, `${taskId}.log`);
1092
+ await fs.writeFile(outputFileAbs, '', 'utf8');
1093
+
1094
+ const task = {
1026
1095
  taskId,
1027
1096
  command,
1028
1097
  cwd: root,
1029
- child: spawn(shellSpec.command, [...shellSpec.args, shellCommandForService(command, shellSpec)], {
1098
+ child: spawn(shellSpec.command, [...shellSpec.args, shellCommandForBackgroundTask(command, shellSpec)], {
1030
1099
  cwd: root,
1031
1100
  stdio: ['ignore', 'pipe', 'pipe']
1032
1101
  }),
1033
1102
  startedAt: Date.now(),
1034
1103
  status: 'starting',
1104
+ intentKind: classifyCommandIntent(command).kind,
1035
1105
  startupConfirmed: false,
1036
1106
  startupSource: '',
1037
1107
  successMatchers,
1038
1108
  portProbe,
1039
1109
  httpProbe,
1110
+ outputFileAbs,
1111
+ outputFile: toWorkspaceRelative(root, outputFileAbs),
1040
1112
  recentLogs: [],
1041
1113
  exitCode: null,
1042
- signal: null
1114
+ signal: null,
1115
+ outputWrite: Promise.resolve()
1043
1116
  };
1044
- serviceRegistry.set(taskId, service);
1117
+ backgroundTaskRegistry.set(taskId, task);
1045
1118
 
1046
- service.closePromise = new Promise((resolve) => {
1047
- service.child.on('close', (code, signal) => {
1048
- service.exitCode = code;
1049
- service.signal = signal;
1050
- service.status = service.status === 'stopped' ? 'stopped' : 'exited';
1119
+ task.closePromise = new Promise((resolve) => {
1120
+ task.child.on('close', (code, signal) => {
1121
+ task.exitCode = code;
1122
+ task.signal = signal;
1123
+ task.status = task.status === 'stopped' ? 'stopped' : 'exited';
1051
1124
  resolve();
1052
1125
  });
1053
1126
  });
1054
1127
 
1055
1128
  const onOutput = (chunk) => {
1056
- appendRecentLogs(service, chunk);
1057
- if (matchesServiceSuccess(service, chunk)) {
1058
- markServiceReady(service, 'output');
1059
- if (service._finishStartup) service._finishStartup();
1129
+ appendRecentOutput(task, chunk);
1130
+ queueBackgroundTaskOutputWrite(task, chunk);
1131
+ if (matchesTaskStartupSuccess(task, chunk)) {
1132
+ markTaskReady(task, 'output');
1133
+ if (task._finishStartup) task._finishStartup();
1060
1134
  }
1061
1135
  };
1062
- service.child.stdout.on('data', onOutput);
1063
- service.child.stderr.on('data', onOutput);
1064
- service.child.on('error', (error) => {
1065
- appendRecentLogs(service, error?.message || String(error));
1066
- service.status = 'exited';
1067
- if (service._finishStartup) service._finishStartup();
1136
+ task.child.stdout.on('data', onOutput);
1137
+ task.child.stderr.on('data', onOutput);
1138
+ task.child.on('error', (error) => {
1139
+ appendRecentOutput(task, error?.message || String(error));
1140
+ queueBackgroundTaskOutputWrite(task, error?.message || String(error));
1141
+ task.status = 'exited';
1142
+ if (task._finishStartup) task._finishStartup();
1068
1143
  });
1069
1144
 
1070
1145
  await new Promise((resolve) => {
@@ -1075,20 +1150,20 @@ async function startService(root, config, args) {
1075
1150
  clearTimeout(timeoutHandle);
1076
1151
  clearInterval(portHandle);
1077
1152
  clearInterval(httpHandle);
1078
- service._finishStartup = null;
1153
+ task._finishStartup = null;
1079
1154
  resolve();
1080
1155
  };
1081
- service._finishStartup = finish;
1082
- if (service.startupConfirmed || service.status === 'exited') {
1156
+ task._finishStartup = finish;
1157
+ if (task.startupConfirmed || task.status === 'exited') {
1083
1158
  finish();
1084
1159
  return;
1085
1160
  }
1086
1161
  const timeoutHandle = setTimeout(() => {
1087
- if (service.status === 'starting') {
1088
- if (!service.startupConfirmed) {
1089
- markServiceReady(service, 'startup_window');
1162
+ if (task.status === 'starting') {
1163
+ if (!task.startupConfirmed) {
1164
+ markTaskReady(task, 'startup_window');
1090
1165
  } else {
1091
- service.status = 'running';
1166
+ task.status = 'running';
1092
1167
  }
1093
1168
  }
1094
1169
  finish();
@@ -1098,74 +1173,60 @@ async function startService(root, config, args) {
1098
1173
  ? setInterval(async () => {
1099
1174
  const open = await probePortOnce(portProbe);
1100
1175
  if (open) {
1101
- markServiceReady(service, 'port_probe');
1176
+ markTaskReady(task, 'port_probe');
1102
1177
  finish();
1103
1178
  }
1104
- }, SERVICE_STARTUP_POLL_MS)
1179
+ }, BACKGROUND_TASK_POLL_MS)
1105
1180
  : null;
1106
1181
  const httpHandle =
1107
1182
  httpProbe
1108
1183
  ? setInterval(async () => {
1109
1184
  const ok = await probeHttpOnce(httpProbe);
1110
1185
  if (ok) {
1111
- markServiceReady(service, 'http_probe');
1186
+ markTaskReady(task, 'http_probe');
1112
1187
  finish();
1113
1188
  }
1114
- }, SERVICE_STARTUP_POLL_MS)
1189
+ }, BACKGROUND_TASK_POLL_MS)
1115
1190
  : null;
1116
- service.child.once('close', () => finish());
1191
+ task.child.once('close', () => finish());
1117
1192
  });
1118
1193
 
1119
- if (service.status === 'starting') {
1120
- service.status = 'running';
1194
+ if (task.status === 'starting') {
1195
+ task.status = 'running';
1121
1196
  }
1122
- return snapshotService(service);
1123
- }
1124
-
1125
- function getServiceOrThrow(taskId) {
1126
- const service = serviceRegistry.get(String(taskId || '').trim());
1127
- if (!service) throw new Error(`Unknown service task: ${taskId}`);
1128
- return service;
1197
+ return snapshotBackgroundTask(task);
1129
1198
  }
1130
1199
 
1131
- async function getServiceStatus(_root, args) {
1132
- const service = getServiceOrThrow(args?.task_id || args?.taskId);
1133
- return snapshotService(service);
1200
+ function getBackgroundTaskOrThrow(taskId) {
1201
+ const task = backgroundTaskRegistry.get(String(taskId || '').trim());
1202
+ if (!task) throw new Error(`Unknown background task: ${taskId}`);
1203
+ return task;
1134
1204
  }
1135
1205
 
1136
- async function listServices() {
1137
- return {
1138
- services: listServiceSnapshots()
1139
- };
1206
+ async function getBackgroundTask(_root, args) {
1207
+ const task = getBackgroundTaskOrThrow(args?.task_id || args?.taskId);
1208
+ return snapshotBackgroundTask(task);
1140
1209
  }
1141
1210
 
1142
- async function getServiceLogs(_root, args) {
1143
- const service = getServiceOrThrow(args?.task_id || args?.taskId);
1144
- const tail = Math.max(1, Math.min(200, Number(args?.tail || 40)));
1145
- const afterCursor = Math.max(0, Number(args?.after_cursor || args?.afterCursor || 0));
1146
- const filtered = afterCursor > 0 ? service.recentLogs.filter((item) => item.cursor > afterCursor) : service.recentLogs;
1147
- const selected = filtered.slice(-tail);
1211
+ async function listBackgroundTasks() {
1148
1212
  return {
1149
- task_id: service.taskId,
1150
- status: service.status,
1151
- recent_logs: selected.map((item) => item.line),
1152
- next_cursor: selected.length > 0 ? selected[selected.length - 1].cursor : afterCursor
1213
+ tasks: listBackgroundTaskSnapshots()
1153
1214
  };
1154
1215
  }
1155
1216
 
1156
- async function stopService(_root, args) {
1157
- const service = getServiceOrThrow(args?.task_id || args?.taskId);
1158
- if (service.status === 'stopped' || service.status === 'exited') {
1159
- return { ...snapshotService(service), stopped: true };
1217
+ async function stopBackgroundTask(_root, args) {
1218
+ const task = getBackgroundTaskOrThrow(args?.task_id || args?.taskId);
1219
+ if (task.status === 'stopped' || task.status === 'exited') {
1220
+ return { ...snapshotBackgroundTask(task), stopped: true };
1160
1221
  }
1161
- service.status = 'stopped';
1162
- terminateChild(service.child, 'SIGTERM');
1163
- setTimeout(() => terminateChild(service.child, 'SIGKILL'), 200);
1222
+ task.status = 'stopped';
1223
+ terminateChild(task.child, 'SIGTERM');
1224
+ setTimeout(() => terminateChild(task.child, 'SIGKILL'), 200);
1164
1225
  await Promise.race([
1165
- service.closePromise,
1226
+ task.closePromise,
1166
1227
  new Promise((resolve) => setTimeout(resolve, 500))
1167
1228
  ]);
1168
- return { ...snapshotService(service), stopped: true };
1229
+ return { ...snapshotBackgroundTask(task), stopped: true };
1169
1230
  }
1170
1231
 
1171
1232
  async function searchCode(root, args) {
@@ -1696,7 +1757,7 @@ async function editTarget(root, args) {
1696
1757
  throw new Error(`edit does not support kind: ${kind}`);
1697
1758
  }
1698
1759
 
1699
- export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSystemEvent }) {
1760
+ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSystemEvent, getTodos, onTodosUpdate }) {
1700
1761
  const emitSystemTool = (event) => {
1701
1762
  if (typeof onSystemEvent === 'function' && event) onSystemEvent(event);
1702
1763
  };
@@ -1824,26 +1885,6 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
1824
1885
  }
1825
1886
  }
1826
1887
  },
1827
- {
1828
- type: 'function',
1829
- function: {
1830
- name: 'glob',
1831
- description:
1832
- 'Find files by glob pattern. Use this for file discovery before read. Aliases like query and directory are accepted. Do not use run with find for normal file lookup.',
1833
- parameters: {
1834
- type: 'object',
1835
- properties: {
1836
- pattern: { type: 'string', description: 'Glob pattern' },
1837
- path: { type: 'string', description: 'Directory to search' },
1838
- query: { type: 'string', description: 'Alias for pattern' },
1839
- directory: { type: 'string', description: 'Alias for path' },
1840
- include_hidden: { type: 'boolean', description: 'Include dotfiles' },
1841
- max_results: { type: 'number', description: 'Max results' }
1842
- },
1843
- required: ['pattern']
1844
- }
1845
- }
1846
- },
1847
1888
  {
1848
1889
  type: 'function',
1849
1890
  function: {
@@ -1930,17 +1971,60 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
1930
1971
  }
1931
1972
  }
1932
1973
  },
1974
+ {
1975
+ type: 'function',
1976
+ function: {
1977
+ name: 'update_todos',
1978
+ description:
1979
+ 'Create or replace the structured todo checklist for the current session. Use this proactively for complex single-task work to track progress. Provide the full current list each time, and keep exactly one item in_progress when work is actively underway.',
1980
+ parameters: {
1981
+ type: 'object',
1982
+ properties: {
1983
+ todos: {
1984
+ type: 'array',
1985
+ items: {
1986
+ type: 'object',
1987
+ properties: {
1988
+ content: { type: 'string', description: 'Imperative task text such as "Run tests"' },
1989
+ activeForm: { type: 'string', description: 'Present continuous form such as "Running tests"' },
1990
+ status: { type: 'string', description: 'pending, in_progress, or completed' }
1991
+ },
1992
+ required: ['content', 'activeForm', 'status']
1993
+ },
1994
+ description: 'The full current todo checklist for this session'
1995
+ }
1996
+ },
1997
+ required: ['todos']
1998
+ }
1999
+ }
2000
+ },
1933
2001
  {
1934
2002
  type: 'function',
1935
2003
  function: {
1936
2004
  name: 'run',
1937
2005
  description:
1938
- 'Run a one-shot shell command such as install, build, or test. Do not use for long-running services or file search.',
2006
+ 'Run a shell command. Use this for one-shot commands like install/build/test, and also for long-running commands by setting run_in_background=true. Long-running commands may also be backgrounded automatically.',
1939
2007
  parameters: {
1940
2008
  type: 'object',
1941
2009
  properties: {
1942
2010
  command: { type: 'string', description: 'Shell command to execute' },
1943
- timeout: { type: 'number', description: 'Timeout in milliseconds' }
2011
+ timeout: { type: 'number', description: 'Timeout in milliseconds' },
2012
+ run_in_background: { type: 'boolean', description: 'Run in the background and return a task handle immediately' },
2013
+ startup_timeout_ms: { type: 'number', description: 'Background startup wait window in milliseconds' },
2014
+ success_matchers: {
2015
+ type: 'array',
2016
+ items: { type: 'string' },
2017
+ description: 'Optional startup success phrases to look for in command output'
2018
+ },
2019
+ port_probe: { type: 'number', description: 'Optional localhost port to probe for readiness' },
2020
+ http_probe: {
2021
+ type: 'object',
2022
+ properties: {
2023
+ url: { type: 'string' },
2024
+ expect_status: { type: 'number' }
2025
+ },
2026
+ description: 'Optional HTTP readiness probe for a background task'
2027
+ }
1944
2028
  },
1945
2029
  required: ['command']
1946
2030
  }
@@ -1983,6 +2067,26 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
1983
2067
  }
1984
2068
  }
1985
2069
  },
2070
+ glob: {
2071
+ type: 'function',
2072
+ function: {
2073
+ name: 'glob',
2074
+ description:
2075
+ 'Find files by glob pattern. Use this when you already know a filename pattern such as src/**/*.ts. Aliases like query and directory are accepted.',
2076
+ parameters: {
2077
+ type: 'object',
2078
+ properties: {
2079
+ pattern: { type: 'string', description: 'Glob pattern' },
2080
+ path: { type: 'string', description: 'Directory to search' },
2081
+ query: { type: 'string', description: 'Alias for pattern' },
2082
+ directory: { type: 'string', description: 'Alias for path' },
2083
+ include_hidden: { type: 'boolean', description: 'Include dotfiles' },
2084
+ max_results: { type: 'number', description: 'Max results' }
2085
+ },
2086
+ required: ['pattern']
2087
+ }
2088
+ }
2089
+ },
1986
2090
  read_ast_node: {
1987
2091
  type: 'function',
1988
2092
  function: {
@@ -2030,80 +2134,132 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
2030
2134
  }
2031
2135
  }
2032
2136
  },
2033
- start_service: {
2137
+ remember_user: {
2034
2138
  type: 'function',
2035
2139
  function: {
2036
- name: 'start_service',
2037
- description:
2038
- 'Start a long-running local service and return a compact handle. Do not use run for watchers, dev servers, or other persistent processes.',
2140
+ name: 'remember_user',
2141
+ description: 'Store a durable user preference, communication habit, or long-term instruction for future sessions. Use this for things like reply style, language, explanation depth, or stable guardrails. Never store secrets, tokens, passwords, or one-off task details.',
2039
2142
  parameters: {
2040
2143
  type: 'object',
2041
2144
  properties: {
2042
- command: { type: 'string' },
2043
- startup_timeout_ms: { type: 'number' },
2044
- success_matchers: {
2045
- type: 'array',
2046
- items: { type: 'string' }
2047
- },
2048
- port_probe: { type: 'number' },
2049
- http_probe: {
2050
- type: 'object',
2051
- properties: {
2052
- url: { type: 'string' },
2053
- expect_status: { type: 'number' }
2054
- }
2055
- }
2145
+ content: { type: 'string', description: 'Stable preference or instruction to remember' },
2146
+ kind: { type: 'string', description: 'preference, workflow, constraint, or warning' },
2147
+ summary: { type: 'string', description: 'Short summary for the memory index' },
2148
+ replace_similar: { type: 'boolean', description: 'Replace an existing similar memory when true' }
2056
2149
  },
2057
- required: ['command']
2150
+ required: ['content']
2058
2151
  }
2059
2152
  }
2060
2153
  },
2061
- list_services: {
2154
+ remember_global: {
2062
2155
  type: 'function',
2063
2156
  function: {
2064
- name: 'list_services',
2065
- description: 'List tracked local services and their current status. Use this to find existing service handles before starting another one.',
2157
+ name: 'remember_global',
2158
+ description: 'Store a durable cross-project workflow, environment fact, or generally reusable lesson that can help across many repositories. Use this for stable habits like preferred search tools or repeatable debugging workflow. Never store secrets.',
2066
2159
  parameters: {
2067
2160
  type: 'object',
2068
- properties: {}
2161
+ properties: {
2162
+ content: { type: 'string' },
2163
+ kind: { type: 'string' },
2164
+ summary: { type: 'string' },
2165
+ replace_similar: { type: 'boolean' }
2166
+ },
2167
+ required: ['content']
2069
2168
  }
2070
2169
  }
2071
2170
  },
2072
- get_service_status: {
2171
+ remember_project: {
2073
2172
  type: 'function',
2074
2173
  function: {
2075
- name: 'get_service_status',
2076
- description: 'Get the status of a started service. Use this to confirm startup or diagnose a stalled service.',
2174
+ name: 'remember_project',
2175
+ description: 'Store a durable project-specific convention, architecture note, key module warning, or local workflow expectation. Use this for repository-specific rules, important files, testing conventions, or architectural boundaries. Never store secrets or transient task state.',
2077
2176
  parameters: {
2078
2177
  type: 'object',
2079
2178
  properties: {
2080
- task_id: { type: 'string' }
2179
+ content: { type: 'string' },
2180
+ kind: { type: 'string' },
2181
+ summary: { type: 'string' },
2182
+ replace_similar: { type: 'boolean' }
2081
2183
  },
2082
- required: ['task_id']
2184
+ required: ['content']
2185
+ }
2186
+ }
2187
+ },
2188
+ list_memory: {
2189
+ type: 'function',
2190
+ function: {
2191
+ name: 'list_memory',
2192
+ description: 'List stored persistent memories for one scope.',
2193
+ parameters: {
2194
+ type: 'object',
2195
+ properties: {
2196
+ scope: { type: 'string', description: 'user, global, or project' }
2197
+ },
2198
+ required: ['scope']
2083
2199
  }
2084
2200
  }
2085
2201
  },
2086
- get_service_logs: {
2202
+ search_memory: {
2087
2203
  type: 'function',
2088
2204
  function: {
2089
- name: 'get_service_logs',
2090
- description: 'Read recent logs from a started service. Use this for targeted diagnosis instead of restarting blindly.',
2205
+ name: 'search_memory',
2206
+ description: 'Search stored persistent memories for one scope.',
2091
2207
  parameters: {
2092
2208
  type: 'object',
2093
2209
  properties: {
2094
- task_id: { type: 'string' },
2095
- tail: { type: 'number' },
2096
- after_cursor: { type: 'number' }
2210
+ scope: { type: 'string', description: 'user, global, or project' },
2211
+ query: { type: 'string', description: 'Search phrase' }
2212
+ },
2213
+ required: ['scope', 'query']
2214
+ }
2215
+ }
2216
+ },
2217
+ forget_memory: {
2218
+ type: 'function',
2219
+ function: {
2220
+ name: 'forget_memory',
2221
+ description: 'Delete a stored persistent memory by id.',
2222
+ parameters: {
2223
+ type: 'object',
2224
+ properties: {
2225
+ scope: { type: 'string', description: 'user, global, or project' },
2226
+ id: { type: 'string', description: 'Memory id to delete' }
2227
+ },
2228
+ required: ['scope', 'id']
2229
+ }
2230
+ }
2231
+ },
2232
+ list_background_tasks: {
2233
+ type: 'function',
2234
+ function: {
2235
+ name: 'list_background_tasks',
2236
+ description:
2237
+ 'List background shell tasks started by run(..., run_in_background=true) or auto-backgrounded by run.',
2238
+ parameters: {
2239
+ type: 'object',
2240
+ properties: {}
2241
+ }
2242
+ }
2243
+ },
2244
+ get_background_task: {
2245
+ type: 'function',
2246
+ function: {
2247
+ name: 'get_background_task',
2248
+ description: 'Get the current status for one background shell task.',
2249
+ parameters: {
2250
+ type: 'object',
2251
+ properties: {
2252
+ task_id: { type: 'string' }
2097
2253
  },
2098
2254
  required: ['task_id']
2099
2255
  }
2100
2256
  }
2101
2257
  },
2102
- stop_service: {
2258
+ stop_background_task: {
2103
2259
  type: 'function',
2104
2260
  function: {
2105
- name: 'stop_service',
2106
- description: 'Stop a started service when it is no longer needed or when you need a clean restart.',
2261
+ name: 'stop_background_task',
2262
+ description: 'Stop a running background shell task when it is no longer needed.',
2107
2263
  parameters: {
2108
2264
  type: 'object',
2109
2265
  properties: {
@@ -2179,12 +2335,71 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
2179
2335
  if (result?.path) await refreshProjectFile(result.path);
2180
2336
  return result;
2181
2337
  },
2338
+ update_todos: async (args = {}) => {
2339
+ const oldTodos = normalizeTodos(typeof getTodos === 'function' ? getTodos() : []);
2340
+ const nextTodos = normalizeTodos(args?.todos);
2341
+ if (typeof onTodosUpdate === 'function') {
2342
+ onTodosUpdate(nextTodos);
2343
+ }
2344
+ return {
2345
+ ok: true,
2346
+ oldTodos,
2347
+ newTodos: nextTodos
2348
+ };
2349
+ },
2182
2350
  run: (args) => runCommand(workspaceRoot, config, args),
2183
- start_service: (args) => startService(workspaceRoot, config, args),
2184
- list_services: () => listServices(workspaceRoot),
2185
- get_service_status: (args) => getServiceStatus(workspaceRoot, args),
2186
- get_service_logs: (args) => getServiceLogs(workspaceRoot, args),
2187
- stop_service: (args) => stopService(workspaceRoot, args),
2351
+ remember_user: async (args = {}) => {
2352
+ const saved = await rememberMemory({
2353
+ scope: 'user',
2354
+ content: args.content,
2355
+ kind: args.kind,
2356
+ summary: args.summary,
2357
+ replaceSimilar: args.replace_similar !== false,
2358
+ workspaceRoot,
2359
+ config
2360
+ });
2361
+ return { ok: true, scope: 'user', memory: saved };
2362
+ },
2363
+ remember_global: async (args = {}) => {
2364
+ const saved = await rememberMemory({
2365
+ scope: 'global',
2366
+ content: args.content,
2367
+ kind: args.kind,
2368
+ summary: args.summary,
2369
+ replaceSimilar: args.replace_similar !== false,
2370
+ workspaceRoot,
2371
+ config
2372
+ });
2373
+ return { ok: true, scope: 'global', memory: saved };
2374
+ },
2375
+ remember_project: async (args = {}) => {
2376
+ const saved = await rememberMemory({
2377
+ scope: 'project',
2378
+ content: args.content,
2379
+ kind: args.kind,
2380
+ summary: args.summary,
2381
+ replaceSimilar: args.replace_similar !== false,
2382
+ workspaceRoot,
2383
+ config
2384
+ });
2385
+ return { ok: true, scope: 'project', memory: saved };
2386
+ },
2387
+ list_memory: async (args = {}) => ({
2388
+ scope: String(args.scope || ''),
2389
+ items: await listMemories({ scope: args.scope, workspaceRoot })
2390
+ }),
2391
+ search_memory: async (args = {}) => ({
2392
+ scope: String(args.scope || ''),
2393
+ query: String(args.query || ''),
2394
+ items: await searchMemories({ scope: args.scope, query: args.query, workspaceRoot })
2395
+ }),
2396
+ forget_memory: async (args = {}) => ({
2397
+ ok: true,
2398
+ ...(await forgetMemory({ scope: args.scope, id: args.id, workspaceRoot }))
2399
+ }),
2400
+ list_background_tasks: () => listBackgroundTasks(workspaceRoot),
2401
+ get_background_task: (args) => getBackgroundTask(workspaceRoot, args),
2402
+ stop_background_task: (args) => stopBackgroundTask(workspaceRoot, args),
2188
2403
  tool_search: (args) => {
2189
2404
  const query = String(args?.query || '').trim().toLowerCase();
2190
2405
  if (query === 'all') {
@@ -2264,6 +2479,17 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
2264
2479
  return `${header}\n${dirs.join('\n')}${dirs.length && files.length ? '\n' : ''}${files.join('\n')}`;
2265
2480
  },
2266
2481
 
2482
+ update_todos(result) {
2483
+ if (!result || typeof result !== 'object') return String(result);
2484
+ const nextTodos = normalizeTodos(result.newTodos);
2485
+ if (nextTodos.length === 0) return 'Todo list cleared.';
2486
+ const lines = nextTodos.map((item) => {
2487
+ const box = item.status === 'completed' ? '[x]' : item.status === 'in_progress' ? '[~]' : '[ ]';
2488
+ return `${box} ${item.content}`;
2489
+ });
2490
+ return ['Updated todo list:', ...lines].join('\n');
2491
+ },
2492
+
2267
2493
  query_project_index(result) {
2268
2494
  if (!result || typeof result !== 'object') return String(result);
2269
2495
  const lines = [];
@@ -2321,6 +2547,18 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
2321
2547
 
2322
2548
  run(result) {
2323
2549
  if (!result || typeof result !== 'object') return String(result);
2550
+ if (result.background) {
2551
+ const parts = [
2552
+ `[background task: ${result.task_id || '?'}]`,
2553
+ `status: ${result.status || 'running'}`
2554
+ ];
2555
+ if (result.command) parts.push(`command: ${String(result.command).slice(0, 200)}`);
2556
+ if (result.output_file) parts.push(`output_file: ${result.output_file}`);
2557
+ if (Array.isArray(result.recent_output) && result.recent_output.length > 0) {
2558
+ parts.push(`recent_output:\n${result.recent_output.slice(0, 6).join('\n')}`);
2559
+ }
2560
+ return parts.join('\n');
2561
+ }
2324
2562
  const command = String(result.command || '').slice(0, 200);
2325
2563
  const stdout = String(result.stdout || '').slice(0, 500);
2326
2564
  const stderr = String(result.stderr || '').slice(0, 500);
@@ -2332,6 +2570,34 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
2332
2570
  return parts.join('\n');
2333
2571
  },
2334
2572
 
2573
+ remember_user(result) {
2574
+ return result?.memory?.content ? `stored user memory: ${result.memory.content}` : JSON.stringify(result);
2575
+ },
2576
+
2577
+ remember_global(result) {
2578
+ return result?.memory?.content ? `stored global memory: ${result.memory.content}` : JSON.stringify(result);
2579
+ },
2580
+
2581
+ remember_project(result) {
2582
+ return result?.memory?.content ? `stored project memory: ${result.memory.content}` : JSON.stringify(result);
2583
+ },
2584
+
2585
+ list_memory(result) {
2586
+ if (!result || typeof result !== 'object' || !Array.isArray(result.items)) return JSON.stringify(result);
2587
+ if (result.items.length === 0) return `No ${result.scope || ''} memories found.`;
2588
+ return result.items.map((item) => `${item.id} [${item.kind}] ${item.content}`).join('\n');
2589
+ },
2590
+
2591
+ search_memory(result) {
2592
+ if (!result || typeof result !== 'object' || !Array.isArray(result.items)) return JSON.stringify(result);
2593
+ if (result.items.length === 0) return `No ${result.scope || ''} memories matched "${result.query || ''}".`;
2594
+ return result.items.map((item) => `${item.id} [${item.kind}] ${item.content}`).join('\n');
2595
+ },
2596
+
2597
+ forget_memory(result) {
2598
+ return `removed ${Number(result?.removed || 0)} memory item(s)`;
2599
+ },
2600
+
2335
2601
  generate_diff(result) {
2336
2602
  if (!result || typeof result !== 'object') return String(result);
2337
2603
  const p = result.path || '';
@@ -2374,38 +2640,23 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
2374
2640
  return `${header}\n${content.slice(0, 1200)}\n... [omitted ${content.length - 1600} chars] ...\n${content.slice(-400)}`;
2375
2641
  },
2376
2642
 
2377
- start_service(result) {
2378
- if (!result || typeof result !== 'object') return String(result);
2379
- const tid = result.task_id || '';
2380
- const status = result.status || 'unknown';
2381
- const confirmed = result.startup_confirmed ? 'ready' : 'starting';
2382
- const url = result.url || '';
2383
- return `${tid} ${status} (${confirmed})${url ? ` -> ${url}` : ''}`;
2384
- },
2385
-
2386
- list_services(result) {
2643
+ list_background_tasks(result) {
2387
2644
  if (!result || typeof result !== 'object') return String(result);
2388
- if (!Array.isArray(result.services)) return JSON.stringify(result);
2389
- if (result.services.length === 0) return 'No services running.';
2390
- return result.services.map((s) => `${s.task_id || '?'} ${s.status || 'unknown'}${s.command ? ` (${s.command.slice(0, 60)})` : ''}`).join('\n');
2645
+ if (!Array.isArray(result.tasks)) return JSON.stringify(result);
2646
+ if (result.tasks.length === 0) return 'No background tasks running.';
2647
+ return result.tasks.map((task) => `${task.task_id || '?'} ${task.status || 'unknown'}${task.command ? ` (${task.command.slice(0, 60)})` : ''}`).join('\n');
2391
2648
  },
2392
2649
 
2393
- get_service_status(result) {
2650
+ get_background_task(result) {
2394
2651
  if (!result || typeof result !== 'object') return String(result);
2395
2652
  const tid = result.task_id || '';
2396
2653
  const status = result.status || 'unknown';
2397
- const url = result.url || '';
2398
- const logs = Array.isArray(result.recent_logs) ? result.recent_logs.slice(-3).join('\n') : '';
2399
- return `${tid} ${status}${url ? ` -> ${url}` : ''}${logs ? `\n${logs}` : ''}`;
2400
- },
2401
-
2402
- get_service_logs(result) {
2403
- if (!result || typeof result !== 'object') return String(result);
2404
- const logs = Array.isArray(result.recent_logs) ? result.recent_logs.join('\n') : '';
2405
- return logs || 'No recent logs.';
2654
+ const outputFile = result.output_file || '';
2655
+ const output = Array.isArray(result.recent_output) ? result.recent_output.slice(-3).join('\n') : '';
2656
+ return `${tid} ${status}${outputFile ? ` -> ${outputFile}` : ''}${output ? `\n${output}` : ''}`;
2406
2657
  },
2407
2658
 
2408
- stop_service(result) {
2659
+ stop_background_task(result) {
2409
2660
  if (!result || typeof result !== 'object') return String(result);
2410
2661
  return `${result.task_id || '?'} stopped${result.exit_code != null ? ` (exit ${result.exit_code})` : ''}`;
2411
2662
  }