tide-commander 1.136.0 → 1.136.2

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.
Files changed (46) hide show
  1. package/dist/assets/{BossLogsModal-B5wtQ9Fh.js → BossLogsModal-B4p16n9B.js} +1 -1
  2. package/dist/assets/{BossSpawnModal-B8twkNNt.js → BossSpawnModal-BHQ7sxeu.js} +1 -1
  3. package/dist/assets/{ControlsModal-BKUVlO45.js → ControlsModal-BcP5-m0h.js} +1 -1
  4. package/dist/assets/{DockerLogsModal-Oa0WYexL.js → DockerLogsModal-C_Gd6YLn.js} +1 -1
  5. package/dist/assets/{EmbeddedEditor-Cnkfr9vJ.js → EmbeddedEditor-GHVeXIn5.js} +1 -1
  6. package/dist/assets/{GmailOAuthSetup-Cfsgqs5D.js → GmailOAuthSetup-VjAKcJf9.js} +1 -1
  7. package/dist/assets/{GoogleOAuthSetup-BZCY7oIc.js → GoogleOAuthSetup-DFbaN0Ry.js} +1 -1
  8. package/dist/assets/{IframeModal-BVaOFzOL.js → IframeModal-BaoVOZTf.js} +1 -1
  9. package/dist/assets/{IntegrationsPanel-CdcwTSOY.js → IntegrationsPanel-Cg4TxOfh.js} +2 -2
  10. package/dist/assets/{LogViewerModal-U0o-ixDu.js → LogViewerModal-CN2GYpgr.js} +1 -1
  11. package/dist/assets/{MonitoringModal-CdYBNEBK.js → MonitoringModal-oN_JjoC2.js} +1 -1
  12. package/dist/assets/{PM2LogsModal-cxSSfOTz.js → PM2LogsModal-BOQ1Im-i.js} +1 -1
  13. package/dist/assets/{RestoreArchivedAreaModal-BkzbecLN.js → RestoreArchivedAreaModal-DYcjqjbA.js} +1 -1
  14. package/dist/assets/{Scene2DCanvas-GGc__FVu.js → Scene2DCanvas-CsBA5ERQ.js} +1 -1
  15. package/dist/assets/{SceneManager-BWyie0tH.js → SceneManager-BqVfBAQc.js} +1 -1
  16. package/dist/assets/{SkillsPanel-DS7QNt0-.js → SkillsPanel-CtfixcgQ.js} +1 -1
  17. package/dist/assets/{SlackMultiInstanceSetup-CjHaygiR.js → SlackMultiInstanceSetup-3nGO_xs6.js} +1 -1
  18. package/dist/assets/{SpawnModal-MWySBWkA.js → SpawnModal-BvUHN-Q1.js} +1 -1
  19. package/dist/assets/{StatisticsModal-D08htYhH.js → StatisticsModal-DIx8l3F0.js} +1 -1
  20. package/dist/assets/{SubordinateAssignmentModal-DULM1xpx.js → SubordinateAssignmentModal-Bw-V4i4-.js} +1 -1
  21. package/dist/assets/{TriggerManagerPanel-nB6R7Nk4.js → TriggerManagerPanel-C2Mz8wVK.js} +1 -1
  22. package/dist/assets/{WorkflowEditorPanel-CKgW8DhB.js → WorkflowEditorPanel-C2RHV8Dx.js} +1 -1
  23. package/dist/assets/{area-logos-CuU1qdkU.js → area-logos-aaW_jqzI.js} +1 -1
  24. package/dist/assets/{index-CLmNQzTF.js → index-B4JG--zt.js} +2 -2
  25. package/dist/assets/{index-CQPpjjTd.js → index-BGz62M0g.js} +1 -1
  26. package/dist/assets/{index-Cc_Ys4_9.js → index-BIUEhCXu.js} +1 -1
  27. package/dist/assets/{index-ih8yuWhL.js → index-BT7u2ftY.js} +1 -1
  28. package/dist/assets/{index-CWYcGGCJ.js → index-BvR0it5l.js} +5 -5
  29. package/dist/assets/{index-8DzLSviG.js → index-BzCUPOjQ.js} +1 -1
  30. package/dist/assets/{index-B6TRH1E2.js → index-CmGwjdPh.js} +1 -1
  31. package/dist/assets/{index-CWiGs6nn.js → index-D6r1xAEg.js} +1 -1
  32. package/dist/assets/{index-Csnk6THj.js → index-DidVGiaJ.js} +2 -2
  33. package/dist/assets/main-Ba3wdQK_.css +1 -0
  34. package/dist/assets/{main-CHE4wC7P.js → main-rDQDtv_U.js} +115 -115
  35. package/dist/assets/{web-C4yrL2Hp.js → web-B5Ma_-eu.js} +1 -1
  36. package/dist/assets/{web-D1Ur_Mtd.js → web-DojkIxYY.js} +1 -1
  37. package/dist/assets/{web-dmnfXY_q.js → web-Dy1328BB.js} +1 -1
  38. package/dist/index.html +2 -2
  39. package/dist/src/packages/server/claude/backend.js +56 -6
  40. package/dist/src/packages/server/claude/runner/stdout-pipeline.js +4 -0
  41. package/dist/src/packages/server/claude/runner/watchdog.js +34 -0
  42. package/dist/src/packages/server/routes/files.js +297 -92
  43. package/dist/src/packages/server/services/runtime-events.js +76 -9
  44. package/dist/src/packages/server/services/runtime-subagents.js +42 -0
  45. package/package.json +1 -1
  46. package/dist/assets/main-DM0iS4EN.css +0 -1
@@ -274,6 +274,60 @@ function findBySuffixMatch(rawPath, walkRoot) {
274
274
  }
275
275
  return null;
276
276
  }
277
+ // The suffix walk skips node_modules for cost, but locally linked / installed
278
+ // packages (e.g. a monorepo's own `tide-api`) legitimately live there — so a
279
+ // path like `tide-api/src/api-core.js` can only be found under a node_modules
280
+ // tree. Instead of walking all of node_modules (huge), we locate WHERE the
281
+ // node_modules dirs are (shallow — they sit at workspace roots, and we never
282
+ // descend INTO one) and then probe the requested path's tail-slices under each.
283
+ const NODE_MODULES_SCAN_MAX_DEPTH = 4;
284
+ const NODE_MODULES_MAX_DIRS = 24;
285
+ function collectNodeModulesDirs(base, depth, out) {
286
+ if (out.length >= NODE_MODULES_MAX_DIRS || depth > NODE_MODULES_SCAN_MAX_DEPTH)
287
+ return;
288
+ let entries;
289
+ try {
290
+ entries = fs.readdirSync(base, { withFileTypes: true });
291
+ }
292
+ catch {
293
+ return;
294
+ }
295
+ for (const e of entries) {
296
+ if (!e.isDirectory())
297
+ continue;
298
+ if (e.name === 'node_modules') {
299
+ out.push(path.join(base, e.name));
300
+ if (out.length >= NODE_MODULES_MAX_DIRS)
301
+ return;
302
+ continue; // don't recurse into node_modules; tail-slice probing covers depth
303
+ }
304
+ if (e.name.startsWith('.') || SUFFIX_WALK_IGNORE.has(e.name))
305
+ continue;
306
+ collectNodeModulesDirs(path.join(base, e.name), depth + 1, out);
307
+ if (out.length >= NODE_MODULES_MAX_DIRS)
308
+ return;
309
+ }
310
+ }
311
+ // Probe the requested path (and each of its tail-slices) under every
312
+ // node_modules dir found beneath `base`. Uses the shared tryCandidate so hits
313
+ // are validated + recorded in `tried`. Returns the first existing file.
314
+ function findInNodeModules(rawPath, base, tryCandidate) {
315
+ if (!fs.existsSync(base))
316
+ return null;
317
+ const segments = rawPath.replace(/^\/+/, '').split(path.sep).filter(Boolean);
318
+ if (segments.length === 0)
319
+ return null;
320
+ const dirs = [];
321
+ collectNodeModulesDirs(base, 0, dirs);
322
+ for (const nm of dirs) {
323
+ for (let i = 0; i < segments.length; i++) {
324
+ const hit = tryCandidate(path.join(nm, ...segments.slice(i)));
325
+ if (hit)
326
+ return hit;
327
+ }
328
+ }
329
+ return null;
330
+ }
277
331
  /**
278
332
  * Resolve a requested file path to an existing file on disk, with fallbacks.
279
333
  * Tries (in order):
@@ -282,8 +336,10 @@ function findBySuffixMatch(rawPath, walkRoot) {
282
336
  * 3. parent-walk — tail slices anchored at baseDir AND each ancestor up to /
283
337
  * 4. git-root — tail slices anchored at the git toplevel from baseDir
284
338
  * 5. suffix-match — depth-limited walk of baseDir, unique trailing-segment match
285
- * 6. area-root verbatim join against each user-configured area directory
286
- * 7. area-suffix-match same depth-limited walk but rooted at each area directory
339
+ * 6. node-modules-match tail slices probed under each node_modules dir beneath baseDir
340
+ * 7. area-root verbatim join against each user-configured area directory
341
+ * 8. area-suffix-match — same depth-limited walk but rooted at each area directory
342
+ * 9. node-modules-match — same node_modules probe but rooted at each area directory
287
343
  * On miss, returns the absolute path requested AND the list of paths tried so
288
344
  * the caller can surface a clear, debuggable error.
289
345
  */
@@ -295,6 +351,17 @@ export function findFileWithFallbacks(rawPath, baseDir) {
295
351
  if (!resolution.ok) {
296
352
  return resolution;
297
353
  }
354
+ // A path that resolves directly to an existing directory is a "browse this
355
+ // folder" request, not a missing file. Short-circuit here so we return a
356
+ // clear directory signal instead of running the whole fallback walk — which
357
+ // rejects the directory at every candidate (tryCandidate skips directories)
358
+ // and would surface dozens of futile "tried" locations before 404-ing.
359
+ try {
360
+ if (fs.existsSync(resolution.path) && fs.statSync(resolution.path).isDirectory()) {
361
+ return { ok: false, status: 400, error: 'Path is a directory', requested: resolution.path };
362
+ }
363
+ }
364
+ catch { /* stat failed (perms/broken symlink) — fall through to normal resolution */ }
298
365
  const tried = [];
299
366
  const seen = new Set();
300
367
  const tryCandidate = (p) => {
@@ -375,7 +442,18 @@ export function findFileWithFallbacks(rawPath, baseDir) {
375
442
  }
376
443
  }
377
444
  }
378
- catch { /* walk failed entirely — fall through to area strategies */ }
445
+ catch { /* walk failed entirely — fall through to node_modules/area strategies */ }
446
+ // node_modules-anchored: the suffix walk above skips node_modules, so a
447
+ // locally-linked package path (e.g. tide-api/src/api-core.js) is only
448
+ // reachable here.
449
+ try {
450
+ const nmHit = findInNodeModules(rawPath, absBase, tryCandidate);
451
+ if (nmHit) {
452
+ rememberResolution(rawPath, nmHit, 'node-modules-match');
453
+ return { ok: true, path: nmHit, strategy: 'node-modules-match' };
454
+ }
455
+ }
456
+ catch { /* scan failed — fall through to area strategies */ }
379
457
  }
380
458
  // Area strategies: try the user's configured area directories. Runs whether
381
459
  // or not baseDir is set — area paths are independent. Capped to keep cold
@@ -415,6 +493,20 @@ export function findFileWithFallbacks(rawPath, baseDir) {
415
493
  }
416
494
  catch { /* walk failed for this area — try next */ }
417
495
  }
496
+ // node_modules-anchored search within each area dir (same reasoning as the
497
+ // baseDir pass above: locally-linked packages under an area's node_modules).
498
+ for (const { areaId, areaName, dir } of areaDirs) {
499
+ if (!fs.existsSync(dir))
500
+ continue;
501
+ try {
502
+ const nmHit = findInNodeModules(rawPath, dir, tryCandidate);
503
+ if (nmHit) {
504
+ rememberResolution(rawPath, nmHit, 'node-modules-match');
505
+ return { ok: true, path: nmHit, strategy: 'node-modules-match', areaId, areaName };
506
+ }
507
+ }
508
+ catch { /* scan failed for this area — try next */ }
509
+ }
418
510
  return {
419
511
  ok: false,
420
512
  status: 404,
@@ -513,10 +605,10 @@ router.get('/resolve', async (req, res) => {
513
605
  }
514
606
  catch { /* skip */ }
515
607
  }
516
- // Search for files matching the basename (searchFiles may re-find .claude/ files)
608
+ // Search for files matching the basename (searchFilesAsync may re-find .claude/ files)
517
609
  const existingPaths = new Set(results.map(r => r.path));
518
610
  const searchResults = [];
519
- searchFiles(searchRoot, basename, searchResults, 20);
611
+ await searchFilesAsync(searchRoot, basename.toLowerCase(), searchResults, 20);
520
612
  for (const r of searchResults) {
521
613
  if (!existingPaths.has(r.path))
522
614
  results.push(r);
@@ -993,43 +1085,61 @@ router.post('/paste', (req, res) => {
993
1085
  res.status(500).json({ error: err.message });
994
1086
  }
995
1087
  });
996
- // Helper function to search files recursively
997
- function searchFiles(dirPath, query, results, maxResults, depth = 0) {
998
- if (results.length >= maxResults || depth > 10)
1088
+ // Directories that are always skipped during file/content search. Kept as a
1089
+ // Set for O(1) lookups (this is checked once per directory entry during the
1090
+ // tree walk). Union of the old filename + content skip lists.
1091
+ const SEARCH_SKIP_DIRS = new Set([
1092
+ 'node_modules', 'dist', 'build', '.git', '__pycache__',
1093
+ 'venv', '.venv', 'target', 'vendor', '.next', '.cache',
1094
+ ]);
1095
+ const MAX_SEARCH_DEPTH = 10;
1096
+ // Helper function to search files recursively (async / non-blocking).
1097
+ // Uses fs.promises so a deep tree walk never blocks the event loop, which
1098
+ // matters on a shared multi-agent server. `queryLower` must be pre-lowercased.
1099
+ async function searchFilesAsync(dirPath, queryLower, results, maxResults, depth = 0) {
1100
+ if (results.length >= maxResults || depth > MAX_SEARCH_DEPTH)
999
1101
  return;
1102
+ let entries;
1000
1103
  try {
1001
- const entries = fs.readdirSync(dirPath, { withFileTypes: true });
1002
- for (const entry of entries) {
1003
- if (results.length >= maxResults)
1004
- break;
1005
- // Skip common non-essential directories
1006
- if (['node_modules', 'dist', '.git', '__pycache__', 'venv', '.venv'].includes(entry.name))
1007
- continue;
1008
- const fullPath = path.join(dirPath, entry.name);
1009
- // Check if name matches query (case-insensitive)
1010
- if (entry.name.toLowerCase().includes(query.toLowerCase())) {
1011
- try {
1012
- const stats = fs.statSync(fullPath);
1013
- results.push({
1014
- name: entry.name,
1015
- path: fullPath,
1016
- isDirectory: entry.isDirectory(),
1017
- size: stats.size,
1018
- extension: entry.isDirectory() ? '' : path.extname(entry.name).toLowerCase(),
1019
- });
1020
- }
1021
- catch {
1022
- // Skip
1023
- }
1104
+ entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
1105
+ }
1106
+ catch {
1107
+ return; // Skip directories we can't read
1108
+ }
1109
+ const subdirs = [];
1110
+ for (const entry of entries) {
1111
+ if (results.length >= maxResults)
1112
+ break;
1113
+ const isDir = entry.isDirectory();
1114
+ // Skip common non-essential directories
1115
+ if (isDir && SEARCH_SKIP_DIRS.has(entry.name))
1116
+ continue;
1117
+ const fullPath = path.join(dirPath, entry.name);
1118
+ // Check if name matches query (case-insensitive)
1119
+ if (entry.name.toLowerCase().includes(queryLower)) {
1120
+ let size = 0;
1121
+ try {
1122
+ size = (await fs.promises.stat(fullPath)).size;
1024
1123
  }
1025
- // Recurse into directories
1026
- if (entry.isDirectory()) {
1027
- searchFiles(fullPath, query, results, maxResults, depth + 1);
1124
+ catch {
1125
+ // Keep size 0 if stat fails (broken symlink etc.) but still surface it
1028
1126
  }
1127
+ results.push({
1128
+ name: entry.name,
1129
+ path: fullPath,
1130
+ isDirectory: isDir,
1131
+ size,
1132
+ extension: isDir ? '' : path.extname(entry.name).toLowerCase(),
1133
+ });
1029
1134
  }
1135
+ if (isDir)
1136
+ subdirs.push(fullPath);
1030
1137
  }
1031
- catch {
1032
- // Skip directories we can't read
1138
+ // Recurse after listing this level so match/recurse order stays bounded.
1139
+ for (const sub of subdirs) {
1140
+ if (results.length >= maxResults)
1141
+ break;
1142
+ await searchFilesAsync(sub, queryLower, results, maxResults, depth + 1);
1033
1143
  }
1034
1144
  }
1035
1145
  // GET /api/files/search - Search for files
@@ -1051,7 +1161,7 @@ router.get('/search', async (req, res) => {
1051
1161
  return;
1052
1162
  }
1053
1163
  const results = [];
1054
- searchFiles(dirPath, query, results, maxResults);
1164
+ await searchFilesAsync(dirPath, query.toLowerCase(), results, maxResults);
1055
1165
  // Sort: files first (more likely what user wants), then by name
1056
1166
  results.sort((a, b) => {
1057
1167
  if (a.isDirectory !== b.isDirectory) {
@@ -1077,67 +1187,155 @@ const TEXT_EXTENSIONS = new Set([
1077
1187
  '.editorconfig', '.prettierrc', '.eslintrc', '.babelrc',
1078
1188
  '.log', '.csv', '.tsv', '.svg', '.vue', '.svelte',
1079
1189
  ]);
1080
- // Helper function to search file contents recursively
1081
- function searchFileContents(dirPath, query, results, maxResults, depth = 0) {
1082
- if (results.length >= maxResults || depth > 10)
1083
- return;
1084
- try {
1085
- const entries = fs.readdirSync(dirPath, { withFileTypes: true });
1086
- const queryLower = query.toLowerCase();
1087
- for (const entry of entries) {
1088
- if (results.length >= maxResults)
1089
- break;
1090
- // Skip common non-essential directories
1091
- if (['node_modules', 'dist', 'build', '.git', '__pycache__', 'venv', '.venv', 'target', 'vendor'].includes(entry.name))
1092
- continue;
1093
- const fullPath = path.join(dirPath, entry.name);
1094
- if (entry.isDirectory()) {
1095
- // Recurse into directories
1096
- searchFileContents(fullPath, query, results, maxResults, depth + 1);
1097
- }
1098
- else {
1099
- // Check if it's a text file we can search
1100
- const ext = path.extname(entry.name).toLowerCase();
1101
- if (!TEXT_EXTENSIONS.has(ext) && ext !== '')
1190
+ // Max matches surfaced per file (both search paths respect this).
1191
+ const MAX_MATCHES_PER_FILE = 5;
1192
+ // Skip files larger than 1MB for content search.
1193
+ const MAX_CONTENT_FILE_SIZE = 1024 * 1024;
1194
+ // Primary content search: shell out to ripgrep. It runs off the event loop as
1195
+ // a child process (never blocking the server) and is orders of magnitude faster
1196
+ // than a JS tree walk. Resolves { ok:false } when rg is missing or errors, so
1197
+ // the caller can fall back to the pure-JS walker.
1198
+ function searchFileContentsRipgrep(dirPath, query, maxResults) {
1199
+ return new Promise((resolve) => {
1200
+ const args = [
1201
+ '--json',
1202
+ '--ignore-case',
1203
+ '--fixed-strings', // treat the query as a literal substring
1204
+ '--max-count', String(MAX_MATCHES_PER_FILE),
1205
+ '--max-filesize', String(MAX_CONTENT_FILE_SIZE),
1206
+ '--max-depth', String(MAX_SEARCH_DEPTH + 1),
1207
+ '--hidden', // include dotfiles (.env, .github, ...)
1208
+ '--no-ignore', // don't honor .gitignore (match old behavior)
1209
+ '--no-messages', // suppress permission/binary warnings on stderr
1210
+ ...[...SEARCH_SKIP_DIRS].flatMap((d) => ['--glob', `!**/${d}/**`]),
1211
+ '--',
1212
+ query,
1213
+ dirPath,
1214
+ ];
1215
+ let child;
1216
+ try {
1217
+ child = spawn('rg', args, { stdio: ['ignore', 'pipe', 'pipe'] });
1218
+ }
1219
+ catch {
1220
+ resolve({ ok: false, results: [] });
1221
+ return;
1222
+ }
1223
+ const byFile = new Map();
1224
+ let buffer = '';
1225
+ let settled = false;
1226
+ const finish = (ok) => {
1227
+ if (settled)
1228
+ return;
1229
+ settled = true;
1230
+ resolve({ ok, results: [...byFile.values()] });
1231
+ };
1232
+ // Spawn failure (e.g. rg not installed) → fall back to the JS walker.
1233
+ child.on('error', () => finish(false));
1234
+ child.stdout?.setEncoding('utf-8');
1235
+ child.stdout?.on('data', (chunk) => {
1236
+ buffer += chunk;
1237
+ let nl;
1238
+ while ((nl = buffer.indexOf('\n')) !== -1) {
1239
+ const line = buffer.slice(0, nl);
1240
+ buffer = buffer.slice(nl + 1);
1241
+ if (!line)
1102
1242
  continue;
1243
+ let evt;
1103
1244
  try {
1104
- const stats = fs.statSync(fullPath);
1105
- // Skip files larger than 1MB
1106
- if (stats.size > 1024 * 1024)
1107
- continue;
1108
- const content = fs.readFileSync(fullPath, 'utf-8');
1109
- const lines = content.split('\n');
1110
- const matches = [];
1111
- for (let i = 0; i < lines.length && matches.length < 5; i++) {
1112
- const line = lines[i];
1113
- if (line.toLowerCase().includes(queryLower)) {
1114
- matches.push({
1115
- line: i + 1,
1116
- content: line.slice(0, 200), // Truncate long lines
1117
- context: {
1118
- before: i > 0 ? lines[i - 1].slice(0, 100) : '',
1119
- after: i < lines.length - 1 ? lines[i + 1].slice(0, 100) : '',
1120
- },
1121
- });
1122
- }
1123
- }
1124
- if (matches.length > 0) {
1125
- results.push({
1126
- path: fullPath,
1127
- name: entry.name,
1128
- extension: ext,
1129
- matches,
1130
- });
1131
- }
1245
+ evt = JSON.parse(line);
1132
1246
  }
1133
1247
  catch {
1134
- // Skip files we can't read (binary, permission issues)
1248
+ continue;
1249
+ }
1250
+ if (evt.type !== 'match')
1251
+ continue;
1252
+ const filePath = evt.data?.path?.text;
1253
+ if (!filePath)
1254
+ continue;
1255
+ let cm = byFile.get(filePath);
1256
+ if (!cm) {
1257
+ // Cap the number of distinct files we track.
1258
+ if (byFile.size >= maxResults) {
1259
+ child.kill();
1260
+ break;
1261
+ }
1262
+ cm = {
1263
+ path: filePath,
1264
+ name: path.basename(filePath),
1265
+ extension: path.extname(filePath).toLowerCase(),
1266
+ matches: [],
1267
+ };
1268
+ byFile.set(filePath, cm);
1135
1269
  }
1270
+ if (cm.matches.length >= MAX_MATCHES_PER_FILE)
1271
+ continue;
1272
+ const text = (evt.data?.lines?.text ?? '').replace(/\r?\n$/, '');
1273
+ cm.matches.push({
1274
+ line: evt.data?.line_number ?? 0,
1275
+ content: text.slice(0, 200),
1276
+ });
1136
1277
  }
1137
- }
1278
+ });
1279
+ // rg exit codes: 0 = matches, 1 = no matches, 2 = error. Only a real error
1280
+ // (or a kill, code null) that produced nothing should trigger the fallback.
1281
+ child.on('close', (code) => finish(!(code === 2 && byFile.size === 0)));
1282
+ });
1283
+ }
1284
+ // Fallback content search (async / non-blocking) used only when ripgrep is
1285
+ // unavailable. Mirrors the old behavior but with fs.promises so it never
1286
+ // blocks the event loop.
1287
+ async function searchFileContentsAsync(dirPath, queryLower, results, maxResults, depth = 0) {
1288
+ if (results.length >= maxResults || depth > MAX_SEARCH_DEPTH)
1289
+ return;
1290
+ let entries;
1291
+ try {
1292
+ entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
1138
1293
  }
1139
1294
  catch {
1140
- // Skip directories we can't read
1295
+ return; // Skip directories we can't read
1296
+ }
1297
+ const subdirs = [];
1298
+ for (const entry of entries) {
1299
+ if (results.length >= maxResults)
1300
+ break;
1301
+ if (entry.isDirectory()) {
1302
+ if (!SEARCH_SKIP_DIRS.has(entry.name))
1303
+ subdirs.push(path.join(dirPath, entry.name));
1304
+ continue;
1305
+ }
1306
+ // Check if it's a text file we can search
1307
+ const ext = path.extname(entry.name).toLowerCase();
1308
+ if (!TEXT_EXTENSIONS.has(ext) && ext !== '')
1309
+ continue;
1310
+ const fullPath = path.join(dirPath, entry.name);
1311
+ try {
1312
+ const stats = await fs.promises.stat(fullPath);
1313
+ if (stats.size > MAX_CONTENT_FILE_SIZE)
1314
+ continue;
1315
+ const content = await fs.promises.readFile(fullPath, 'utf-8');
1316
+ const lines = content.split('\n');
1317
+ const matches = [];
1318
+ for (let i = 0; i < lines.length && matches.length < MAX_MATCHES_PER_FILE; i++) {
1319
+ const line = lines[i];
1320
+ if (line.toLowerCase().includes(queryLower)) {
1321
+ matches.push({
1322
+ line: i + 1,
1323
+ content: line.slice(0, 200), // Truncate long lines
1324
+ });
1325
+ }
1326
+ }
1327
+ if (matches.length > 0) {
1328
+ results.push({ path: fullPath, name: entry.name, extension: ext, matches });
1329
+ }
1330
+ }
1331
+ catch {
1332
+ // Skip files we can't read (binary, permission issues)
1333
+ }
1334
+ }
1335
+ for (const sub of subdirs) {
1336
+ if (results.length >= maxResults)
1337
+ break;
1338
+ await searchFileContentsAsync(sub, queryLower, results, maxResults, depth + 1);
1141
1339
  }
1142
1340
  }
1143
1341
  // GET /api/files/search-content - Search file contents
@@ -1162,8 +1360,15 @@ router.get('/search-content', async (req, res) => {
1162
1360
  res.status(404).json({ error: 'Directory not found' });
1163
1361
  return;
1164
1362
  }
1165
- const results = [];
1166
- searchFileContents(dirPath, query, results, maxResults);
1363
+ let results = [];
1364
+ const rg = await searchFileContentsRipgrep(dirPath, query, maxResults);
1365
+ if (rg.ok) {
1366
+ results = rg.results;
1367
+ }
1368
+ else {
1369
+ // ripgrep unavailable — fall back to the pure-JS async walker.
1370
+ await searchFileContentsAsync(dirPath, query.toLowerCase(), results, maxResults);
1371
+ }
1167
1372
  res.json({ results });
1168
1373
  }
1169
1374
  catch (err) {
@@ -1,7 +1,7 @@
1
1
  import { loadSession } from '../claude/session-loader.js';
2
2
  import * as agentService from './agent-service.js';
3
3
  import { clearPendingSilentContextRefresh, consumeStepCompleteReceived, markStepCompleteReceived, } from './runtime-watchdog.js';
4
- import { handleTaskToolResult, handleTaskToolStart } from './runtime-subagents.js';
4
+ import { addPendingBackgroundTask, clearPendingBackgroundTasks, getActiveSubagentByToolUseId, handleTaskToolResult, handleTaskToolStart, hasPendingBackgroundTasks, resolvePendingBackgroundTask, resolvePendingBackgroundTaskByTaskId, } from './runtime-subagents.js';
5
5
  const DEFAULT_CLAUDE_CONTEXT_WINDOW = 200000;
6
6
  const DEFAULT_CODEX_CONTEXT_WINDOW = 258400;
7
7
  const CODEX_ROLLING_CONTEXT_TURNS = 40;
@@ -173,8 +173,56 @@ export function createRuntimeEventHandlers(deps) {
173
173
  break;
174
174
  case 'tool_result':
175
175
  handleTaskToolResult(agentId, event, log);
176
+ if (event.toolUseId) {
177
+ // Any tool_result for a tracked toolUseId means that task delivered its
178
+ // result. The CLI assigns task_ids to slow Bash commands too, so resolution
179
+ // must NOT be limited to Task/Agent tools — that gap pinned agents 'working'.
180
+ resolvePendingBackgroundTask(agentId, event.toolUseId);
181
+ }
176
182
  agentService.updateAgent(agentId, { currentTool: undefined });
177
183
  break;
184
+ case 'task_started':
185
+ // Tool promoted to a background task (async Task/Agent, slow Bash): it can
186
+ // outlive the parent turn, so step_complete must not flip the agent idle
187
+ // while it runs.
188
+ if (event.toolUseId) {
189
+ addPendingBackgroundTask(agentId, event.toolUseId, event.taskId);
190
+ log.log(`[task] Background task pending for ${agentId}: toolUseId=${event.toolUseId}${event.taskId ? `, taskId=${event.taskId}` : ''}`);
191
+ }
192
+ break;
193
+ case 'task_notification': {
194
+ // A background task finished and the CLI is waking the model with its result.
195
+ if (!event.toolUseId && event.taskId) {
196
+ resolvePendingBackgroundTaskByTaskId(agentId, event.taskId);
197
+ }
198
+ if (event.toolUseId) {
199
+ resolvePendingBackgroundTask(agentId, event.toolUseId);
200
+ // Complete the subagent now (its launch-stub tool_result was suppressed).
201
+ if (getActiveSubagentByToolUseId(event.toolUseId)) {
202
+ const completion = {
203
+ type: 'tool_result',
204
+ toolName: 'Agent',
205
+ toolUseId: event.toolUseId,
206
+ };
207
+ handleTaskToolResult(agentId, completion, log);
208
+ emitEvent(agentId, completion);
209
+ }
210
+ }
211
+ if (processActive && agent.status === 'idle') {
212
+ agentService.updateAgent(agentId, { status: 'working' });
213
+ }
214
+ break;
215
+ }
216
+ case 'text':
217
+ case 'thinking':
218
+ // The model produced output — it's working, even right after a step_complete
219
+ // (background-task wakes often start with text/thinking, not a tool call).
220
+ // Subagent-bridged events (parentToolUseId set) don't count: the JSONL watcher
221
+ // can drain trailing lines after the parent is genuinely done.
222
+ if (processActive && agent.status === 'idle' && !event.parentToolUseId) {
223
+ agentService.updateAgent(agentId, { status: 'working' });
224
+ }
225
+ break;
178
226
  case 'usage_snapshot': {
179
227
  // Skip subagent events — their token counts reflect the subagent's context,
180
228
  // not the parent's. Updating the parent's contextUsed here would corrupt it.
@@ -356,14 +404,26 @@ export function createRuntimeEventHandlers(deps) {
356
404
  }
357
405
  agentService.updateAgent(agentId, updates);
358
406
  if (!isCodexProvider && !isOpencodeProvider) {
359
- setTimeout(() => {
360
- log.log(`[step_complete] Setting status to idle for agent ${agentId} (lastTask: ${agent.lastAssignedTask})`);
361
- agentService.updateAgent(agentId, {
362
- status: 'idle',
363
- currentTask: undefined,
364
- currentTool: undefined,
365
- });
366
- }, 200);
407
+ if (hasPendingBackgroundTasks(agentId)) {
408
+ // The turn ended but background tasks are still running — the CLI will wake
409
+ // the model with a <task-notification> when they finish. Not idle yet.
410
+ log.log(`[step_complete] Agent ${agentId} has pending background task(s) — keeping status '${agent.status}'`);
411
+ agentService.updateAgent(agentId, { currentTool: undefined });
412
+ }
413
+ else {
414
+ setTimeout(() => {
415
+ if (hasPendingBackgroundTasks(agentId)) {
416
+ log.log(`[step_complete] Agent ${agentId} gained pending background task(s) — skipping idle`);
417
+ return;
418
+ }
419
+ log.log(`[step_complete] Setting status to idle for agent ${agentId} (lastTask: ${agent.lastAssignedTask})`);
420
+ agentService.updateAgent(agentId, {
421
+ status: 'idle',
422
+ currentTask: undefined,
423
+ currentTool: undefined,
424
+ });
425
+ }, 200);
426
+ }
367
427
  }
368
428
  else {
369
429
  log.log(`[step_complete] ${isCodexProvider ? 'Codex' : 'OpenCode'} agent ${agentId} will be set idle on process completion`);
@@ -374,6 +434,9 @@ export function createRuntimeEventHandlers(deps) {
374
434
  break;
375
435
  }
376
436
  case 'error':
437
+ // Background tasks are children of the CLI process — an errored run means
438
+ // they're gone; a stale pending set would pin the agent 'working' forever.
439
+ clearPendingBackgroundTasks(agentId);
377
440
  agentService.updateAgent(agentId, { status: 'error' });
378
441
  break;
379
442
  case 'context_stats':
@@ -401,6 +464,8 @@ export function createRuntimeEventHandlers(deps) {
401
464
  }
402
465
  }
403
466
  function handleComplete(agentId, success) {
467
+ // Process closed — any background tasks died with it.
468
+ clearPendingBackgroundTasks(agentId);
404
469
  const receivedStepComplete = consumeStepCompleteReceived(agentId);
405
470
  const agent = agentService.getAgent(agentId);
406
471
  const isCodexProvider = (agent?.provider ?? 'claude') === 'codex';
@@ -438,6 +503,8 @@ export function createRuntimeEventHandlers(deps) {
438
503
  }
439
504
  }
440
505
  function handleError(agentId, error) {
506
+ // Same as 'error' events: a failed run takes its background tasks with it.
507
+ clearPendingBackgroundTasks(agentId);
441
508
  const agent = agentService.getAgent(agentId);
442
509
  const timestamp = new Date().toISOString();
443
510
  const isCodexProvider = (agent?.provider ?? 'claude') === 'codex';
@@ -1,6 +1,11 @@
1
1
  let subagentCounter = 0;
2
2
  const activeSubagents = new Map();
3
3
  const subagentIdToToolUseId = new Map();
4
+ // Background tasks: the CLI assigns task_ids to async Task/Agent launches AND to slow
5
+ // Bash commands. The parent turn can end while these still run — the CLI wakes the
6
+ // model with a task-notification when each finishes. While any are pending the agent
7
+ // must NOT be flipped to idle.
8
+ const pendingBackgroundTasks = new Map(); // parentAgentId -> toolUseId -> taskId
4
9
  function generateSubagentId() {
5
10
  return `sub_${Date.now().toString(36)}_${(subagentCounter++).toString(36)}`;
6
11
  }
@@ -41,6 +46,42 @@ export function handleTaskToolResult(agentId, event, log) {
41
46
  activeSubagents.delete(event.toolUseId);
42
47
  subagentIdToToolUseId.delete(subagent.id);
43
48
  }
49
+ export function addPendingBackgroundTask(agentId, toolUseId, taskId) {
50
+ let tasks = pendingBackgroundTasks.get(agentId);
51
+ if (!tasks) {
52
+ tasks = new Map();
53
+ pendingBackgroundTasks.set(agentId, tasks);
54
+ }
55
+ tasks.set(toolUseId, taskId);
56
+ }
57
+ export function resolvePendingBackgroundTask(agentId, toolUseId) {
58
+ const tasks = pendingBackgroundTasks.get(agentId);
59
+ if (!tasks?.delete(toolUseId)) {
60
+ return false;
61
+ }
62
+ if (tasks.size === 0) {
63
+ pendingBackgroundTasks.delete(agentId);
64
+ }
65
+ return true;
66
+ }
67
+ export function resolvePendingBackgroundTaskByTaskId(agentId, taskId) {
68
+ const tasks = pendingBackgroundTasks.get(agentId);
69
+ if (!tasks) {
70
+ return false;
71
+ }
72
+ for (const [toolUseId, pendingTaskId] of tasks) {
73
+ if (pendingTaskId === taskId) {
74
+ return resolvePendingBackgroundTask(agentId, toolUseId);
75
+ }
76
+ }
77
+ return false;
78
+ }
79
+ export function hasPendingBackgroundTasks(agentId) {
80
+ return (pendingBackgroundTasks.get(agentId)?.size ?? 0) > 0;
81
+ }
82
+ export function clearPendingBackgroundTasks(agentId) {
83
+ pendingBackgroundTasks.delete(agentId);
84
+ }
44
85
  export function getActiveSubagentByToolUseId(toolUseId) {
45
86
  return activeSubagents.get(toolUseId);
46
87
  }
@@ -50,5 +91,6 @@ export function getActiveSubagentsForAgent(parentAgentId) {
50
91
  export function resetSubagentStateForTests() {
51
92
  activeSubagents.clear();
52
93
  subagentIdToToolUseId.clear();
94
+ pendingBackgroundTasks.clear();
53
95
  subagentCounter = 0;
54
96
  }