codemini-cli 0.3.2 → 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';
@@ -18,19 +19,60 @@ 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
21
  import { forgetMemory, listMemories, rememberMemory, searchMemories } from './memory-store.js';
21
- const SERVICE_RECENT_LOG_LIMIT = 80;
22
- const SERVICE_STARTUP_POLL_MS = 150;
23
- const serviceRegistry = new Map();
24
- let serviceCounter = 0;
25
- let serviceLogCursorCounter = 0;
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
+ }
26
42
 
27
43
  function resolveInWorkspace(root, targetPath = '.') {
28
44
  const absRoot = path.resolve(root);
45
+ const realRoot = fsSync.realpathSync.native(absRoot);
29
46
  const absTarget = path.resolve(absRoot, targetPath);
30
- 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) {
64
+ throw new Error(`Path escapes workspace: ${targetPath}`);
65
+ }
66
+
67
+ const resolvedTarget = path.join(resolvedProbe, path.relative(probe, absTarget));
68
+ if (!isWithinResolvedRoot(realRoot, resolvedTarget)) {
31
69
  throw new Error(`Path escapes workspace: ${targetPath}`);
32
70
  }
33
- return absTarget;
71
+ return resolvedTarget;
72
+ }
73
+
74
+ function getBackgroundTasksDir(root) {
75
+ return path.join(resolveInWorkspace(root, '.codemini'), 'tasks');
34
76
  }
35
77
 
36
78
  function toWorkspaceRelative(root, absPath) {
@@ -233,56 +275,68 @@ function normalizeFileTypes(args = {}) {
233
275
  return [...new Set(merged)];
234
276
  }
235
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
+
236
299
  async function walkTextFiles(root, startPath = '.', fileTypes = []) {
237
300
  const abs = resolveInWorkspace(root, startPath);
238
- const out = [];
239
301
  const allowedExts = new Set((Array.isArray(fileTypes) ? fileTypes : []).map((item) => `.${String(item || '').replace(/^\./, '')}`));
240
302
 
241
303
  async function visit(current) {
242
304
  const stat = await fs.stat(current);
243
305
  if (stat.isDirectory()) {
244
306
  const name = path.basename(current);
245
- if (SKIP_DIRS.has(name)) return;
307
+ if (SKIP_DIRS.has(name)) return [];
246
308
  const entries = await fs.readdir(current);
247
- for (const entry of entries) {
248
- await visit(path.join(current, entry));
249
- }
250
- return;
309
+ const nested = await mapLimit(entries, WALKER_CONCURRENCY, async (entry) => visit(path.join(current, entry)));
310
+ return nested.flat();
251
311
  }
252
- if (!detectTextFile(current)) return;
253
- if (allowedExts.size > 0 && !allowedExts.has(path.extname(current).toLowerCase())) return;
254
- out.push(current);
312
+ if (!detectTextFile(current)) return [];
313
+ if (allowedExts.size > 0 && !allowedExts.has(path.extname(current).toLowerCase())) return [];
314
+ return [current];
255
315
  }
256
316
 
257
- await visit(abs);
258
- return out;
317
+ return visit(abs);
259
318
  }
260
319
 
261
320
  async function walkWorkspaceEntries(root, startPath = '.', { includeHidden = false } = {}) {
262
321
  const abs = resolveInWorkspace(root, startPath);
263
- const out = [];
264
322
 
265
323
  async function visit(current) {
266
324
  const stat = await fs.stat(current);
267
325
  const relative = toWorkspaceRelative(root, current) || '.';
268
326
  const name = path.basename(current);
269
327
 
270
- if (!includeHidden && name.startsWith('.') && relative !== '.') return;
328
+ if (!includeHidden && name.startsWith('.') && relative !== '.') return [];
271
329
  if (stat.isDirectory()) {
272
- if (SKIP_DIRS.has(name) && relative !== '.') return;
273
- out.push({ path: relative, name, type: 'dir' });
330
+ if (SKIP_DIRS.has(name) && relative !== '.') return [];
274
331
  const entries = await fs.readdir(current);
275
- for (const entry of entries) {
276
- await visit(path.join(current, entry));
277
- }
278
- 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()];
279
334
  }
280
335
 
281
- out.push({ path: relative, name, type: 'file' });
336
+ return [{ path: relative, name, type: 'file' }];
282
337
  }
283
338
 
284
- await visit(abs);
285
- return out;
339
+ return visit(abs);
286
340
  }
287
341
 
288
342
  function globToRegex(pattern) {
@@ -840,18 +894,6 @@ async function runCommand(root, config, args) {
840
894
  if (!command.trim()) {
841
895
  throw new Error('run requires command');
842
896
  }
843
- if (isLikelyLongRunningCommand(command)) {
844
- const intent = classifyCommandIntent(command);
845
- const labelMap = {
846
- 'frontend-service': 'frontend service',
847
- 'backend-service': 'backend service',
848
- 'database-service': 'database service',
849
- 'docker-service': 'Docker service',
850
- service: 'long-running service'
851
- };
852
- const label = labelMap[intent.kind] || 'long-running service';
853
- throw new Error(`Command looks like a ${label}. Use start_service instead of run.`);
854
- }
855
897
  if (
856
898
  !config.policy.allow_dangerous_commands &&
857
899
  isDangerousCommand(command, config.policy.blocked_command_patterns)
@@ -866,6 +908,16 @@ async function runCommand(root, config, args) {
866
908
  );
867
909
  }
868
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
+
869
921
  const result = await runShellCommand({
870
922
  command,
871
923
  cwd: root,
@@ -875,9 +927,9 @@ async function runCommand(root, config, args) {
875
927
  return { ...result, command };
876
928
  }
877
929
 
878
- function nextServiceId() {
879
- serviceCounter += 1;
880
- return `svc_${String(serviceCounter).padStart(3, '0')}`;
930
+ function nextBackgroundTaskId() {
931
+ backgroundTaskCounter += 1;
932
+ return `task_${String(backgroundTaskCounter).padStart(3, '0')}`;
881
933
  }
882
934
 
883
935
  function normalizeSuccessMatchers(items = []) {
@@ -885,39 +937,39 @@ function normalizeSuccessMatchers(items = []) {
885
937
  return items.map((item) => String(item || '').trim()).filter(Boolean);
886
938
  }
887
939
 
888
- function shellCommandForService(command, shellSpec) {
940
+ function shellCommandForBackgroundTask(command, shellSpec) {
889
941
  return process.platform !== 'win32' && /(?:^|\/)bash(?:\.exe)?$/i.test(shellSpec.command)
890
942
  ? `exec ${command}`
891
943
  : command;
892
944
  }
893
945
 
894
- function appendRecentLogs(service, chunk) {
946
+ function appendRecentOutput(task, chunk) {
895
947
  const lines = String(chunk || '')
896
948
  .split(/\r?\n/)
897
949
  .map((line) => trimLinePreview(line, 220))
898
950
  .filter(Boolean);
899
951
  if (lines.length === 0) return;
900
952
  for (const line of lines) {
901
- serviceLogCursorCounter += 1;
902
- service.recentLogs.push({ cursor: serviceLogCursorCounter, line });
953
+ backgroundTaskLogCursorCounter += 1;
954
+ task.recentLogs.push({ cursor: backgroundTaskLogCursorCounter, line });
903
955
  }
904
- if (service.recentLogs.length > SERVICE_RECENT_LOG_LIMIT) {
905
- 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);
906
958
  }
907
959
  }
908
960
 
909
- function matchesServiceSuccess(service, text) {
961
+ function matchesTaskStartupSuccess(task, text) {
910
962
  const value = String(text || '');
911
963
  if (!value) return false;
912
964
  if (hasReadyOutput(value)) return true;
913
- return service.successMatchers.some((matcher) => value.toLowerCase().includes(matcher.toLowerCase()));
965
+ return task.successMatchers.some((matcher) => value.toLowerCase().includes(matcher.toLowerCase()));
914
966
  }
915
967
 
916
- function markServiceReady(service, source = 'output') {
917
- if (service.startupConfirmed) return;
918
- service.startupConfirmed = true;
919
- service.startupSource = source;
920
- 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';
921
973
  }
922
974
 
923
975
  function serviceUrlForPort(port) {
@@ -936,34 +988,38 @@ function normalizeHttpProbe(value) {
936
988
  };
937
989
  }
938
990
 
939
- function snapshotService(service, tail = 12) {
940
- const recentLogs = Array.isArray(service.recentLogs)
941
- ? 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)
942
994
  : [];
943
995
  const latestCursor =
944
- Array.isArray(service.recentLogs) && service.recentLogs.length > 0
945
- ? service.recentLogs[service.recentLogs.length - 1].cursor
996
+ Array.isArray(task.recentLogs) && task.recentLogs.length > 0
997
+ ? task.recentLogs[task.recentLogs.length - 1].cursor
946
998
  : 0;
947
999
  return {
948
- task_id: service.taskId,
949
- pid: service.child?.pid || null,
950
- command: service.command,
951
- cwd: service.cwd,
952
- status: service.status,
953
- startup_confirmed: service.startupConfirmed,
954
- startup_source: service.startupSource || '',
955
- http_probe: service.httpProbe || undefined,
956
- url: serviceUrlForPort(service.portProbe) || undefined,
957
- 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,
958
1014
  log_cursor: latestCursor,
959
- exit_code: service.exitCode ?? undefined,
960
- signal: service.signal ?? undefined,
961
- duration_ms: Date.now() - service.startedAt
1015
+ exit_code: task.exitCode ?? undefined,
1016
+ signal: task.signal ?? undefined,
1017
+ duration_ms: Date.now() - task.startedAt
962
1018
  };
963
1019
  }
964
1020
 
965
- function listServiceSnapshots() {
966
- return Array.from(serviceRegistry.values()).map((service) => snapshotService(service, 4));
1021
+ function listBackgroundTaskSnapshots() {
1022
+ return Array.from(backgroundTaskRegistry.values()).map((task) => snapshotBackgroundTask(task, 4));
967
1023
  }
968
1024
 
969
1025
  function probePortOnce(port, host = '127.0.0.1', timeoutMs = 250) {
@@ -1001,9 +1057,16 @@ async function probeHttpOnce(httpProbe, timeoutMs = 400) {
1001
1057
  }
1002
1058
  }
1003
1059
 
1004
- 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) {
1005
1068
  const command = String(args?.command || args?.cmd || '').trim();
1006
- if (!command) throw new Error('start_service requires command');
1069
+ if (!command) throw new Error('run requires command');
1007
1070
  if (
1008
1071
  !config.policy.allow_dangerous_commands &&
1009
1072
  isDangerousCommand(command, config.policy.blocked_command_patterns)
@@ -1018,54 +1081,65 @@ async function startService(root, config, args) {
1018
1081
  }
1019
1082
 
1020
1083
  const shellSpec = resolveShell(config.shell.default);
1021
- const taskId = nextServiceId();
1084
+ const taskId = nextBackgroundTaskId();
1022
1085
  const startupTimeoutMs = Math.max(250, Number(args?.startup_timeout_ms || args?.startupTimeoutMs || 20000));
1023
1086
  const successMatchers = normalizeSuccessMatchers(args?.success_matchers || args?.successMatchers);
1024
1087
  const portProbe = Number(args?.port_probe || args?.portProbe || 0) || 0;
1025
1088
  const httpProbe = normalizeHttpProbe(args?.http_probe || args?.httpProbe);
1026
- 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 = {
1027
1095
  taskId,
1028
1096
  command,
1029
1097
  cwd: root,
1030
- child: spawn(shellSpec.command, [...shellSpec.args, shellCommandForService(command, shellSpec)], {
1098
+ child: spawn(shellSpec.command, [...shellSpec.args, shellCommandForBackgroundTask(command, shellSpec)], {
1031
1099
  cwd: root,
1032
1100
  stdio: ['ignore', 'pipe', 'pipe']
1033
1101
  }),
1034
1102
  startedAt: Date.now(),
1035
1103
  status: 'starting',
1104
+ intentKind: classifyCommandIntent(command).kind,
1036
1105
  startupConfirmed: false,
1037
1106
  startupSource: '',
1038
1107
  successMatchers,
1039
1108
  portProbe,
1040
1109
  httpProbe,
1110
+ outputFileAbs,
1111
+ outputFile: toWorkspaceRelative(root, outputFileAbs),
1041
1112
  recentLogs: [],
1042
1113
  exitCode: null,
1043
- signal: null
1114
+ signal: null,
1115
+ outputWrite: Promise.resolve()
1044
1116
  };
1045
- serviceRegistry.set(taskId, service);
1117
+ backgroundTaskRegistry.set(taskId, task);
1046
1118
 
1047
- service.closePromise = new Promise((resolve) => {
1048
- service.child.on('close', (code, signal) => {
1049
- service.exitCode = code;
1050
- service.signal = signal;
1051
- 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';
1052
1124
  resolve();
1053
1125
  });
1054
1126
  });
1055
1127
 
1056
1128
  const onOutput = (chunk) => {
1057
- appendRecentLogs(service, chunk);
1058
- if (matchesServiceSuccess(service, chunk)) {
1059
- markServiceReady(service, 'output');
1060
- 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();
1061
1134
  }
1062
1135
  };
1063
- service.child.stdout.on('data', onOutput);
1064
- service.child.stderr.on('data', onOutput);
1065
- service.child.on('error', (error) => {
1066
- appendRecentLogs(service, error?.message || String(error));
1067
- service.status = 'exited';
1068
- 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();
1069
1143
  });
1070
1144
 
1071
1145
  await new Promise((resolve) => {
@@ -1076,20 +1150,20 @@ async function startService(root, config, args) {
1076
1150
  clearTimeout(timeoutHandle);
1077
1151
  clearInterval(portHandle);
1078
1152
  clearInterval(httpHandle);
1079
- service._finishStartup = null;
1153
+ task._finishStartup = null;
1080
1154
  resolve();
1081
1155
  };
1082
- service._finishStartup = finish;
1083
- if (service.startupConfirmed || service.status === 'exited') {
1156
+ task._finishStartup = finish;
1157
+ if (task.startupConfirmed || task.status === 'exited') {
1084
1158
  finish();
1085
1159
  return;
1086
1160
  }
1087
1161
  const timeoutHandle = setTimeout(() => {
1088
- if (service.status === 'starting') {
1089
- if (!service.startupConfirmed) {
1090
- markServiceReady(service, 'startup_window');
1162
+ if (task.status === 'starting') {
1163
+ if (!task.startupConfirmed) {
1164
+ markTaskReady(task, 'startup_window');
1091
1165
  } else {
1092
- service.status = 'running';
1166
+ task.status = 'running';
1093
1167
  }
1094
1168
  }
1095
1169
  finish();
@@ -1099,74 +1173,60 @@ async function startService(root, config, args) {
1099
1173
  ? setInterval(async () => {
1100
1174
  const open = await probePortOnce(portProbe);
1101
1175
  if (open) {
1102
- markServiceReady(service, 'port_probe');
1176
+ markTaskReady(task, 'port_probe');
1103
1177
  finish();
1104
1178
  }
1105
- }, SERVICE_STARTUP_POLL_MS)
1179
+ }, BACKGROUND_TASK_POLL_MS)
1106
1180
  : null;
1107
1181
  const httpHandle =
1108
1182
  httpProbe
1109
1183
  ? setInterval(async () => {
1110
1184
  const ok = await probeHttpOnce(httpProbe);
1111
1185
  if (ok) {
1112
- markServiceReady(service, 'http_probe');
1186
+ markTaskReady(task, 'http_probe');
1113
1187
  finish();
1114
1188
  }
1115
- }, SERVICE_STARTUP_POLL_MS)
1189
+ }, BACKGROUND_TASK_POLL_MS)
1116
1190
  : null;
1117
- service.child.once('close', () => finish());
1191
+ task.child.once('close', () => finish());
1118
1192
  });
1119
1193
 
1120
- if (service.status === 'starting') {
1121
- service.status = 'running';
1194
+ if (task.status === 'starting') {
1195
+ task.status = 'running';
1122
1196
  }
1123
- return snapshotService(service);
1197
+ return snapshotBackgroundTask(task);
1124
1198
  }
1125
1199
 
1126
- function getServiceOrThrow(taskId) {
1127
- const service = serviceRegistry.get(String(taskId || '').trim());
1128
- if (!service) throw new Error(`Unknown service task: ${taskId}`);
1129
- return 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;
1130
1204
  }
1131
1205
 
1132
- async function getServiceStatus(_root, args) {
1133
- const service = getServiceOrThrow(args?.task_id || args?.taskId);
1134
- return snapshotService(service);
1206
+ async function getBackgroundTask(_root, args) {
1207
+ const task = getBackgroundTaskOrThrow(args?.task_id || args?.taskId);
1208
+ return snapshotBackgroundTask(task);
1135
1209
  }
1136
1210
 
1137
- async function listServices() {
1211
+ async function listBackgroundTasks() {
1138
1212
  return {
1139
- services: listServiceSnapshots()
1213
+ tasks: listBackgroundTaskSnapshots()
1140
1214
  };
1141
1215
  }
1142
1216
 
1143
- async function getServiceLogs(_root, args) {
1144
- const service = getServiceOrThrow(args?.task_id || args?.taskId);
1145
- const tail = Math.max(1, Math.min(200, Number(args?.tail || 40)));
1146
- const afterCursor = Math.max(0, Number(args?.after_cursor || args?.afterCursor || 0));
1147
- const filtered = afterCursor > 0 ? service.recentLogs.filter((item) => item.cursor > afterCursor) : service.recentLogs;
1148
- const selected = filtered.slice(-tail);
1149
- return {
1150
- task_id: service.taskId,
1151
- status: service.status,
1152
- recent_logs: selected.map((item) => item.line),
1153
- next_cursor: selected.length > 0 ? selected[selected.length - 1].cursor : afterCursor
1154
- };
1155
- }
1156
-
1157
- async function stopService(_root, args) {
1158
- const service = getServiceOrThrow(args?.task_id || args?.taskId);
1159
- if (service.status === 'stopped' || service.status === 'exited') {
1160
- 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 };
1161
1221
  }
1162
- service.status = 'stopped';
1163
- terminateChild(service.child, 'SIGTERM');
1164
- setTimeout(() => terminateChild(service.child, 'SIGKILL'), 200);
1222
+ task.status = 'stopped';
1223
+ terminateChild(task.child, 'SIGTERM');
1224
+ setTimeout(() => terminateChild(task.child, 'SIGKILL'), 200);
1165
1225
  await Promise.race([
1166
- service.closePromise,
1226
+ task.closePromise,
1167
1227
  new Promise((resolve) => setTimeout(resolve, 500))
1168
1228
  ]);
1169
- return { ...snapshotService(service), stopped: true };
1229
+ return { ...snapshotBackgroundTask(task), stopped: true };
1170
1230
  }
1171
1231
 
1172
1232
  async function searchCode(root, args) {
@@ -1697,7 +1757,7 @@ async function editTarget(root, args) {
1697
1757
  throw new Error(`edit does not support kind: ${kind}`);
1698
1758
  }
1699
1759
 
1700
- export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSystemEvent }) {
1760
+ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSystemEvent, getTodos, onTodosUpdate }) {
1701
1761
  const emitSystemTool = (event) => {
1702
1762
  if (typeof onSystemEvent === 'function' && event) onSystemEvent(event);
1703
1763
  };
@@ -1825,26 +1885,6 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
1825
1885
  }
1826
1886
  }
1827
1887
  },
1828
- {
1829
- type: 'function',
1830
- function: {
1831
- name: 'glob',
1832
- description:
1833
- '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.',
1834
- parameters: {
1835
- type: 'object',
1836
- properties: {
1837
- pattern: { type: 'string', description: 'Glob pattern' },
1838
- path: { type: 'string', description: 'Directory to search' },
1839
- query: { type: 'string', description: 'Alias for pattern' },
1840
- directory: { type: 'string', description: 'Alias for path' },
1841
- include_hidden: { type: 'boolean', description: 'Include dotfiles' },
1842
- max_results: { type: 'number', description: 'Max results' }
1843
- },
1844
- required: ['pattern']
1845
- }
1846
- }
1847
- },
1848
1888
  {
1849
1889
  type: 'function',
1850
1890
  function: {
@@ -1931,17 +1971,60 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
1931
1971
  }
1932
1972
  }
1933
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
+ },
1934
2001
  {
1935
2002
  type: 'function',
1936
2003
  function: {
1937
2004
  name: 'run',
1938
2005
  description:
1939
- '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.',
1940
2007
  parameters: {
1941
2008
  type: 'object',
1942
2009
  properties: {
1943
2010
  command: { type: 'string', description: 'Shell command to execute' },
1944
- 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
+ }
1945
2028
  },
1946
2029
  required: ['command']
1947
2030
  }
@@ -1961,215 +2044,208 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
1961
2044
  required: ['query']
1962
2045
  }
1963
2046
  }
1964
- },
1965
- {
2047
+ }
2048
+ ];
2049
+
2050
+ const deferredDefinitions = {
2051
+ ast_query: {
1966
2052
  type: 'function',
1967
2053
  function: {
1968
- name: 'remember_user',
1969
- 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.',
2054
+ name: 'ast_query',
2055
+ description:
2056
+ 'Run a Tree-sitter query on a code file and return ast_target objects. Use this when you need node-scoped reads or edits for functions, classes, or methods.',
1970
2057
  parameters: {
1971
2058
  type: 'object',
1972
2059
  properties: {
1973
- content: { type: 'string', description: 'Stable preference or instruction to remember' },
1974
- kind: { type: 'string', description: 'preference, workflow, constraint, or warning' },
1975
- summary: { type: 'string', description: 'Short summary for the memory index' },
1976
- replace_similar: { type: 'boolean', description: 'Replace an existing similar memory when true' }
2060
+ path: { type: 'string' },
2061
+ language: { type: 'string' },
2062
+ query: { type: 'string' },
2063
+ capture_name: { type: 'string' },
2064
+ max_results: { type: 'number' }
1977
2065
  },
1978
- required: ['content']
2066
+ required: ['path', 'query']
1979
2067
  }
1980
2068
  }
1981
2069
  },
1982
- {
2070
+ glob: {
1983
2071
  type: 'function',
1984
2072
  function: {
1985
- name: 'remember_global',
1986
- 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.',
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.',
1987
2076
  parameters: {
1988
2077
  type: 'object',
1989
2078
  properties: {
1990
- content: { type: 'string' },
1991
- kind: { type: 'string' },
1992
- summary: { type: 'string' },
1993
- replace_similar: { type: 'boolean' }
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' }
1994
2085
  },
1995
- required: ['content']
2086
+ required: ['pattern']
1996
2087
  }
1997
2088
  }
1998
2089
  },
1999
- {
2090
+ read_ast_node: {
2000
2091
  type: 'function',
2001
2092
  function: {
2002
- name: 'remember_project',
2003
- 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.',
2093
+ name: 'read_ast_node',
2094
+ description:
2095
+ 'Read a previously selected AST node with compact structural context. Use this after ast_query before a scoped structural edit.',
2004
2096
  parameters: {
2005
2097
  type: 'object',
2006
2098
  properties: {
2007
- content: { type: 'string' },
2008
- kind: { type: 'string' },
2009
- summary: { type: 'string' },
2010
- replace_similar: { type: 'boolean' }
2099
+ path: { type: 'string' },
2100
+ language: { type: 'string' },
2101
+ ast_target: { type: 'object' }
2011
2102
  },
2012
- required: ['content']
2103
+ required: ['path', 'ast_target']
2013
2104
  }
2014
2105
  }
2015
2106
  },
2016
- {
2107
+ generate_diff: {
2017
2108
  type: 'function',
2018
2109
  function: {
2019
- name: 'list_memory',
2020
- description: 'List stored persistent memories for one scope.',
2110
+ name: 'generate_diff',
2111
+ description: 'Generate a unified diff for proposed content. Use this when you want to preview or prepare a patch before applying it.',
2021
2112
  parameters: {
2022
2113
  type: 'object',
2023
2114
  properties: {
2024
- scope: { type: 'string', description: 'user, global, or project' }
2115
+ path: { type: 'string' },
2116
+ new_content: { type: 'string' }
2025
2117
  },
2026
- required: ['scope']
2118
+ required: ['path', 'new_content']
2027
2119
  }
2028
2120
  }
2029
2121
  },
2030
- {
2122
+ patch: {
2031
2123
  type: 'function',
2032
2124
  function: {
2033
- name: 'search_memory',
2034
- description: 'Search stored persistent memories for one scope.',
2125
+ name: 'patch',
2126
+ description: 'Apply one or more unified diff hunks to workspace files. Use this for prepared unified diffs instead of ad-hoc shell patching.',
2035
2127
  parameters: {
2036
2128
  type: 'object',
2037
2129
  properties: {
2038
- scope: { type: 'string', description: 'user, global, or project' },
2039
- query: { type: 'string', description: 'Search phrase' }
2130
+ patch: { type: 'string' },
2131
+ content: { type: 'string' }
2040
2132
  },
2041
- required: ['scope', 'query']
2133
+ required: ['patch']
2042
2134
  }
2043
2135
  }
2044
2136
  },
2045
- {
2137
+ remember_user: {
2046
2138
  type: 'function',
2047
2139
  function: {
2048
- name: 'forget_memory',
2049
- description: 'Delete a stored persistent memory by id.',
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.',
2050
2142
  parameters: {
2051
2143
  type: 'object',
2052
2144
  properties: {
2053
- scope: { type: 'string', description: 'user, global, or project' },
2054
- id: { type: 'string', description: 'Memory id to delete' }
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' }
2055
2149
  },
2056
- required: ['scope', 'id']
2150
+ required: ['content']
2057
2151
  }
2058
2152
  }
2059
- }
2060
- ];
2061
-
2062
- const deferredDefinitions = {
2063
- ast_query: {
2153
+ },
2154
+ remember_global: {
2064
2155
  type: 'function',
2065
2156
  function: {
2066
- name: 'ast_query',
2067
- description:
2068
- 'Run a Tree-sitter query on a code file and return ast_target objects. Use this when you need node-scoped reads or edits for functions, classes, or methods.',
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.',
2069
2159
  parameters: {
2070
2160
  type: 'object',
2071
2161
  properties: {
2072
- path: { type: 'string' },
2073
- language: { type: 'string' },
2074
- query: { type: 'string' },
2075
- capture_name: { type: 'string' },
2076
- max_results: { type: 'number' }
2162
+ content: { type: 'string' },
2163
+ kind: { type: 'string' },
2164
+ summary: { type: 'string' },
2165
+ replace_similar: { type: 'boolean' }
2077
2166
  },
2078
- required: ['path', 'query']
2167
+ required: ['content']
2079
2168
  }
2080
2169
  }
2081
2170
  },
2082
- read_ast_node: {
2171
+ remember_project: {
2083
2172
  type: 'function',
2084
2173
  function: {
2085
- name: 'read_ast_node',
2086
- description:
2087
- 'Read a previously selected AST node with compact structural context. Use this after ast_query before a scoped structural edit.',
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.',
2088
2176
  parameters: {
2089
2177
  type: 'object',
2090
2178
  properties: {
2091
- path: { type: 'string' },
2092
- language: { type: 'string' },
2093
- ast_target: { type: 'object' }
2179
+ content: { type: 'string' },
2180
+ kind: { type: 'string' },
2181
+ summary: { type: 'string' },
2182
+ replace_similar: { type: 'boolean' }
2094
2183
  },
2095
- required: ['path', 'ast_target']
2184
+ required: ['content']
2096
2185
  }
2097
2186
  }
2098
2187
  },
2099
- generate_diff: {
2188
+ list_memory: {
2100
2189
  type: 'function',
2101
2190
  function: {
2102
- name: 'generate_diff',
2103
- description: 'Generate a unified diff for proposed content. Use this when you want to preview or prepare a patch before applying it.',
2191
+ name: 'list_memory',
2192
+ description: 'List stored persistent memories for one scope.',
2104
2193
  parameters: {
2105
2194
  type: 'object',
2106
2195
  properties: {
2107
- path: { type: 'string' },
2108
- new_content: { type: 'string' }
2196
+ scope: { type: 'string', description: 'user, global, or project' }
2109
2197
  },
2110
- required: ['path', 'new_content']
2198
+ required: ['scope']
2111
2199
  }
2112
2200
  }
2113
2201
  },
2114
- patch: {
2202
+ search_memory: {
2115
2203
  type: 'function',
2116
2204
  function: {
2117
- name: 'patch',
2118
- description: 'Apply one or more unified diff hunks to workspace files. Use this for prepared unified diffs instead of ad-hoc shell patching.',
2205
+ name: 'search_memory',
2206
+ description: 'Search stored persistent memories for one scope.',
2119
2207
  parameters: {
2120
2208
  type: 'object',
2121
2209
  properties: {
2122
- patch: { type: 'string' },
2123
- content: { type: 'string' }
2210
+ scope: { type: 'string', description: 'user, global, or project' },
2211
+ query: { type: 'string', description: 'Search phrase' }
2124
2212
  },
2125
- required: ['patch']
2213
+ required: ['scope', 'query']
2126
2214
  }
2127
2215
  }
2128
2216
  },
2129
- start_service: {
2217
+ forget_memory: {
2130
2218
  type: 'function',
2131
2219
  function: {
2132
- name: 'start_service',
2133
- description:
2134
- 'Start a long-running local service and return a compact handle. Do not use run for watchers, dev servers, or other persistent processes.',
2220
+ name: 'forget_memory',
2221
+ description: 'Delete a stored persistent memory by id.',
2135
2222
  parameters: {
2136
2223
  type: 'object',
2137
2224
  properties: {
2138
- command: { type: 'string' },
2139
- startup_timeout_ms: { type: 'number' },
2140
- success_matchers: {
2141
- type: 'array',
2142
- items: { type: 'string' }
2143
- },
2144
- port_probe: { type: 'number' },
2145
- http_probe: {
2146
- type: 'object',
2147
- properties: {
2148
- url: { type: 'string' },
2149
- expect_status: { type: 'number' }
2150
- }
2151
- }
2225
+ scope: { type: 'string', description: 'user, global, or project' },
2226
+ id: { type: 'string', description: 'Memory id to delete' }
2152
2227
  },
2153
- required: ['command']
2228
+ required: ['scope', 'id']
2154
2229
  }
2155
2230
  }
2156
2231
  },
2157
- list_services: {
2232
+ list_background_tasks: {
2158
2233
  type: 'function',
2159
2234
  function: {
2160
- name: 'list_services',
2161
- description: 'List tracked local services and their current status. Use this to find existing service handles before starting another one.',
2235
+ name: 'list_background_tasks',
2236
+ description:
2237
+ 'List background shell tasks started by run(..., run_in_background=true) or auto-backgrounded by run.',
2162
2238
  parameters: {
2163
2239
  type: 'object',
2164
2240
  properties: {}
2165
2241
  }
2166
2242
  }
2167
2243
  },
2168
- get_service_status: {
2244
+ get_background_task: {
2169
2245
  type: 'function',
2170
2246
  function: {
2171
- name: 'get_service_status',
2172
- description: 'Get the status of a started service. Use this to confirm startup or diagnose a stalled service.',
2247
+ name: 'get_background_task',
2248
+ description: 'Get the current status for one background shell task.',
2173
2249
  parameters: {
2174
2250
  type: 'object',
2175
2251
  properties: {
@@ -2179,27 +2255,11 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
2179
2255
  }
2180
2256
  }
2181
2257
  },
2182
- get_service_logs: {
2258
+ stop_background_task: {
2183
2259
  type: 'function',
2184
2260
  function: {
2185
- name: 'get_service_logs',
2186
- description: 'Read recent logs from a started service. Use this for targeted diagnosis instead of restarting blindly.',
2187
- parameters: {
2188
- type: 'object',
2189
- properties: {
2190
- task_id: { type: 'string' },
2191
- tail: { type: 'number' },
2192
- after_cursor: { type: 'number' }
2193
- },
2194
- required: ['task_id']
2195
- }
2196
- }
2197
- },
2198
- stop_service: {
2199
- type: 'function',
2200
- function: {
2201
- name: 'stop_service',
2202
- 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.',
2203
2263
  parameters: {
2204
2264
  type: 'object',
2205
2265
  properties: {
@@ -2275,6 +2335,18 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
2275
2335
  if (result?.path) await refreshProjectFile(result.path);
2276
2336
  return result;
2277
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
+ },
2278
2350
  run: (args) => runCommand(workspaceRoot, config, args),
2279
2351
  remember_user: async (args = {}) => {
2280
2352
  const saved = await rememberMemory({
@@ -2325,11 +2397,9 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
2325
2397
  ok: true,
2326
2398
  ...(await forgetMemory({ scope: args.scope, id: args.id, workspaceRoot }))
2327
2399
  }),
2328
- start_service: (args) => startService(workspaceRoot, config, args),
2329
- list_services: () => listServices(workspaceRoot),
2330
- get_service_status: (args) => getServiceStatus(workspaceRoot, args),
2331
- get_service_logs: (args) => getServiceLogs(workspaceRoot, args),
2332
- stop_service: (args) => stopService(workspaceRoot, args),
2400
+ list_background_tasks: () => listBackgroundTasks(workspaceRoot),
2401
+ get_background_task: (args) => getBackgroundTask(workspaceRoot, args),
2402
+ stop_background_task: (args) => stopBackgroundTask(workspaceRoot, args),
2333
2403
  tool_search: (args) => {
2334
2404
  const query = String(args?.query || '').trim().toLowerCase();
2335
2405
  if (query === 'all') {
@@ -2409,6 +2479,17 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
2409
2479
  return `${header}\n${dirs.join('\n')}${dirs.length && files.length ? '\n' : ''}${files.join('\n')}`;
2410
2480
  },
2411
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
+
2412
2493
  query_project_index(result) {
2413
2494
  if (!result || typeof result !== 'object') return String(result);
2414
2495
  const lines = [];
@@ -2466,6 +2547,18 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
2466
2547
 
2467
2548
  run(result) {
2468
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
+ }
2469
2562
  const command = String(result.command || '').slice(0, 200);
2470
2563
  const stdout = String(result.stdout || '').slice(0, 500);
2471
2564
  const stderr = String(result.stderr || '').slice(0, 500);
@@ -2547,38 +2640,23 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
2547
2640
  return `${header}\n${content.slice(0, 1200)}\n... [omitted ${content.length - 1600} chars] ...\n${content.slice(-400)}`;
2548
2641
  },
2549
2642
 
2550
- start_service(result) {
2643
+ list_background_tasks(result) {
2551
2644
  if (!result || typeof result !== 'object') return String(result);
2552
- const tid = result.task_id || '';
2553
- const status = result.status || 'unknown';
2554
- const confirmed = result.startup_confirmed ? 'ready' : 'starting';
2555
- const url = result.url || '';
2556
- return `${tid} ${status} (${confirmed})${url ? ` -> ${url}` : ''}`;
2557
- },
2558
-
2559
- list_services(result) {
2560
- if (!result || typeof result !== 'object') return String(result);
2561
- if (!Array.isArray(result.services)) return JSON.stringify(result);
2562
- if (result.services.length === 0) return 'No services running.';
2563
- 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');
2564
2648
  },
2565
2649
 
2566
- get_service_status(result) {
2650
+ get_background_task(result) {
2567
2651
  if (!result || typeof result !== 'object') return String(result);
2568
2652
  const tid = result.task_id || '';
2569
2653
  const status = result.status || 'unknown';
2570
- const url = result.url || '';
2571
- const logs = Array.isArray(result.recent_logs) ? result.recent_logs.slice(-3).join('\n') : '';
2572
- return `${tid} ${status}${url ? ` -> ${url}` : ''}${logs ? `\n${logs}` : ''}`;
2573
- },
2574
-
2575
- get_service_logs(result) {
2576
- if (!result || typeof result !== 'object') return String(result);
2577
- const logs = Array.isArray(result.recent_logs) ? result.recent_logs.join('\n') : '';
2578
- 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}` : ''}`;
2579
2657
  },
2580
2658
 
2581
- stop_service(result) {
2659
+ stop_background_task(result) {
2582
2660
  if (!result || typeof result !== 'object') return String(result);
2583
2661
  return `${result.task_id || '?'} stopped${result.exit_code != null ? ` (exit ${result.exit_code})` : ''}`;
2584
2662
  }