mixdog 0.9.23 → 0.9.24

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 (84) hide show
  1. package/package.json +1 -1
  2. package/scripts/boot-smoke.mjs +1 -1
  3. package/scripts/build-runtime-windows.ps1 +242 -242
  4. package/scripts/channel-daemon-smoke.mjs +327 -9
  5. package/scripts/channel-daemon-stub.mjs +12 -1
  6. package/scripts/debounced-skills-async-save-test.mjs +57 -0
  7. package/scripts/explore-bench-tmp.mjs +17 -0
  8. package/scripts/find-fuzzy-hidden-test.mjs +145 -0
  9. package/scripts/mcp-grace-deferred-test.mjs +149 -0
  10. package/scripts/recall-usecase-cases.json +1 -1
  11. package/scripts/smoke-runtime-negative.ps1 +106 -106
  12. package/scripts/tool-efficiency-diag.mjs +1 -1
  13. package/scripts/tool-smoke.mjs +38 -30
  14. package/src/rules/agent/30-explorer.md +6 -0
  15. package/src/rules/lead/02-channels.md +3 -3
  16. package/src/rules/shared/01-tool.md +11 -4
  17. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +76 -1
  18. package/src/runtime/agent/orchestrator/config.mjs +33 -7
  19. package/src/runtime/agent/orchestrator/context/collect.mjs +43 -8
  20. package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +39 -0
  21. package/src/runtime/agent/orchestrator/mcp/client.mjs +145 -31
  22. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +38 -1
  23. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +39 -1
  24. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +24 -7
  25. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +11 -1
  26. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +7 -3
  27. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +40 -6
  28. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +2 -1
  29. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +13 -5
  30. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +62 -24
  31. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +7 -6
  32. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +20 -0
  33. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +34 -3
  34. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +220 -27
  35. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +61 -0
  36. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +43 -16
  37. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +54 -5
  38. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +97 -54
  39. package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +49 -31
  40. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +35 -2
  41. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +70 -21
  42. package/src/runtime/channels/lib/crash-log.mjs +4 -2
  43. package/src/runtime/channels/lib/output-forwarder.mjs +2 -1
  44. package/src/runtime/channels/lib/owned-runtime.mjs +65 -180
  45. package/src/runtime/channels/lib/owner-heartbeat.mjs +9 -13
  46. package/src/runtime/channels/lib/runtime-paths.mjs +6 -6
  47. package/src/runtime/channels/lib/tool-dispatch.mjs +9 -17
  48. package/src/runtime/channels/lib/tool-format.mjs +7 -2
  49. package/src/runtime/channels/lib/worker-main.mjs +9 -28
  50. package/src/runtime/memory/lib/query-handlers.mjs +4 -1
  51. package/src/runtime/memory/lib/recall-format.mjs +7 -3
  52. package/src/runtime/memory/tool-defs.mjs +1 -1
  53. package/src/runtime/shared/atomic-file.mjs +130 -2
  54. package/src/runtime/shared/background-tasks.mjs +1 -1
  55. package/src/runtime/shared/config.mjs +53 -1
  56. package/src/runtime/shared/tool-execution-contract.mjs +1 -1
  57. package/src/runtime/shared/tool-surface.mjs +19 -0
  58. package/src/runtime/shared/update-checker.mjs +3 -0
  59. package/src/runtime/shared/user-data-guard.mjs +66 -0
  60. package/src/session-runtime/config-lifecycle.mjs +175 -15
  61. package/src/session-runtime/mcp-glue.mjs +30 -0
  62. package/src/session-runtime/runtime-core.mjs +91 -7
  63. package/src/session-runtime/session-turn-api.mjs +42 -16
  64. package/src/session-runtime/tool-catalog.mjs +44 -0
  65. package/src/standalone/channel-admin.mjs +32 -3
  66. package/src/standalone/channel-daemon-client.mjs +3 -1
  67. package/src/standalone/channel-daemon-transport.mjs +202 -8
  68. package/src/standalone/channel-daemon.mjs +54 -17
  69. package/src/standalone/channel-worker.mjs +18 -7
  70. package/src/standalone/explore-tool.mjs +87 -15
  71. package/src/tui/App.jsx +2 -2
  72. package/src/tui/components/StatusLine.jsx +3 -3
  73. package/src/tui/components/ToolExecution.jsx +14 -2
  74. package/src/tui/components/TranscriptItem.jsx +1 -1
  75. package/src/tui/dist/index.mjs +209 -44
  76. package/src/tui/engine/agent-job-feed.mjs +5 -0
  77. package/src/tui/engine/notification-plan.mjs +5 -0
  78. package/src/tui/engine/session-api.mjs +6 -1
  79. package/src/tui/engine/tool-card-results.mjs +14 -5
  80. package/src/tui/engine/turn.mjs +9 -2
  81. package/src/tui/engine.mjs +31 -12
  82. package/src/ui/statusline-agents.mjs +36 -0
  83. package/src/ui/statusline.mjs +15 -5
  84. package/src/runtime/channels/lib/seat-lock.mjs +0 -196
@@ -22,6 +22,7 @@ import {
22
22
  getCachedReadOnlyStat,
23
23
  statPathsForMtime,
24
24
  lstatPathsForMtime,
25
+ registerCacheInvalidationListener,
25
26
  } from './cache-layers.mjs';
26
27
  import {
27
28
  compileSimpleGlob,
@@ -339,6 +340,106 @@ export async function executeTreeTool(args, workDir, options = {}) {
339
340
  return out;
340
341
  }
341
342
 
343
+ // ── Broad-enumeration cache (shared `rg --files` sweep) ──────────────────
344
+ // A `rg --files` sweep of a root depends ONLY on (root, hidden, depth,
345
+ // includeNoise) — NOT on the per-query narrowing. Yet both the fuzzy-find
346
+ // broad pass and the find_files broad fast path re-run that full sweep for
347
+ // every query item AND for every concurrent caller (measured 1-4s each when
348
+ // 8 explorer sub-sessions hit the same root). Cache the PARSED file list per
349
+ // key with in-flight promise dedup (N concurrent callers share ONE sweep)
350
+ // plus a short TTL for serial reuse. Truncated/partial sweeps are
351
+ // known-incomplete and are NEVER cached.
352
+ const FIND_ENUM_CACHE = new Map(); // key -> { files, expiresAt }
353
+ const FIND_ENUM_INFLIGHT = new Map(); // key -> Promise<{files,truncated,partial}>
354
+
355
+ // The broad enumeration is a DERIVED cache the scope/path invalidation layer
356
+ // does not otherwise know about — a file created/renamed after a sweep would
357
+ // stay invisible to broad find reuse for the whole TTL. Drop all entries on any
358
+ // write-invalidation event (TTL remains the secondary bound). Full clear is
359
+ // fine: entries are cheap to rebuild.
360
+ registerCacheInvalidationListener(() => {
361
+ FIND_ENUM_CACHE.clear();
362
+ FIND_ENUM_INFLIGHT.clear();
363
+ });
364
+
365
+ function findEnumTtlMs() {
366
+ const raw = process.env.MIXDOG_FIND_ENUM_CACHE_TTL_MS;
367
+ if (raw == null || raw === '') return 30000;
368
+ const n = Number(raw);
369
+ if (!Number.isFinite(n) || n < 0) return 30000; // malformed → default
370
+ return Math.floor(n); // 0 = disabled
371
+ }
372
+
373
+ function findEnumKey({ root, hidden, depth, includeNoise }) {
374
+ return `${root}\u0000${hidden ? 1 : 0}\u0000${depth ?? ''}\u0000${includeNoise ? 1 : 0}`;
375
+ }
376
+
377
+ // Parse `rg --files` stdout into the same normalized relative-path list both
378
+ // broad passes build (strip trailing CR, drop empties, strip leading `./`,
379
+ // forward-slash). Module-level so the cache and both call sites agree.
380
+ function parseRgFileList(stdout) {
381
+ return String(stdout)
382
+ .split('\n')
383
+ .map((p) => (p.endsWith('\r') ? p.slice(0, -1) : p))
384
+ .filter((p) => p.length > 0)
385
+ .map((p) => normalizeOutputPath(p.replace(/^\.[/\\]/, '')));
386
+ }
387
+
388
+ // Run (or reuse) the broad `rg --files` sweep for a root. Returns
389
+ // { files, truncated, partial }. The returned `files` array is SHARED — callers
390
+ // must treat it as read-only. `rgArgs` must be the broad-pass args (no per-query
391
+ // narrowing); the cache key is the 4 dims only, so any caller producing an
392
+ // equivalent sweep for the same dims reuses the result.
393
+ async function getBroadEnumeration({ root, hidden, depth, includeNoise, rgArgs, cwd, runRgImpl = runRg }) {
394
+ const ttl = findEnumTtlMs();
395
+ const key = findEnumKey({ root, hidden, depth, includeNoise });
396
+ if (ttl > 0) {
397
+ const hit = FIND_ENUM_CACHE.get(key);
398
+ if (hit && hit.expiresAt > Date.now()) {
399
+ return { files: hit.files, truncated: false, partial: false };
400
+ }
401
+ if (hit) FIND_ENUM_CACHE.delete(key); // expired
402
+ const inflight = FIND_ENUM_INFLIGHT.get(key);
403
+ if (inflight) return inflight;
404
+ }
405
+ const run = (async () => {
406
+ const stdout = await runRgImpl(rgArgs, { cwd });
407
+ const truncated = Boolean(stdout && typeof stdout === 'object' && stdout.truncated);
408
+ const partial = Boolean(stdout && typeof stdout === 'object' && stdout.partial);
409
+ const files = parseRgFileList(stdout);
410
+ // Never cache a truncated/partial sweep — it is known-incomplete, so a
411
+ // later query with a larger head_limit must re-run the enumeration.
412
+ if (ttl > 0 && !truncated && !partial) {
413
+ FIND_ENUM_CACHE.set(key, { files, expiresAt: Date.now() + ttl });
414
+ }
415
+ return { files, truncated, partial };
416
+ })();
417
+ if (ttl > 0) {
418
+ FIND_ENUM_INFLIGHT.set(key, run);
419
+ try { return await run; }
420
+ finally { FIND_ENUM_INFLIGHT.delete(key); }
421
+ }
422
+ return run;
423
+ }
424
+
425
+ // Best-effort warm of the broad enumeration for a root using the `find` tool's
426
+ // DEFAULT flags (hidden:true, includeNoise:false, depth:unbounded). Swallows
427
+ // all errors — a failed prewarm must never surface or block the caller.
428
+ export async function prewarmFindEnumeration(root) {
429
+ try {
430
+ if (!root || typeof root !== 'string') return;
431
+ const hidden = true, includeNoise = false, depth = null;
432
+ const rgArgs = ['--files', '--no-ignore', '--hidden'];
433
+ for (const ex of DEFAULT_IGNORE_GLOBS) rgArgs.push('--glob', ex);
434
+ rgArgs.push('.');
435
+ await getBroadEnumeration({
436
+ root: normalizeOutputPath(root),
437
+ hidden, depth, includeNoise,
438
+ rgArgs, cwd: root,
439
+ });
440
+ } catch { /* best-effort warm; never surface */ }
441
+ }
442
+
342
443
  // Fuzzy filename search (nucleo-style): collect the file
343
444
  // list via `rg --files`, then rank by subsequence score. `list.fuzzy` still
344
445
  // routes here for hidden backward compatibility, but the model-facing tool is
@@ -372,7 +473,11 @@ export async function executeFuzzyFindTool(args, workDir, options = {}) {
372
473
  const fullPath = resolveAgainstCwd(inputPath, workDir);
373
474
  const guardFull = listGuardPath(fullPath);
374
475
  if (guardFull) return guardFull;
375
- const hidden = Boolean(args.hidden);
476
+ // Fuzzy find defaults to searching dot-directories (hidden:true) so
477
+ // machine-wide discovery reaches paths like ~/.mixdog/data/…; callers
478
+ // opt out with hidden:false. .git and other noise dirs are still pruned
479
+ // via DEFAULT_IGNORE_GLOBS below (unless include_noise).
480
+ const hidden = args.hidden === false ? false : true;
376
481
  const includeNoise = Boolean(args.include_noise);
377
482
  // head_limit:0 means "no cap" per list semantics; default is intentionally
378
483
  // compact so ambiguous discovery does not dump a huge candidate list.
@@ -392,36 +497,108 @@ export async function executeFuzzyFindTool(args, workDir, options = {}) {
392
497
  });
393
498
  const cached = cacheGet(cacheKey);
394
499
  if (cached !== null) return cached;
395
- const rgArgs = ['--files'];
396
- if (hidden) rgArgs.push('--hidden');
397
- if (depth != null) rgArgs.push('--max-depth', String(depth));
500
+ // --no-ignore: match the find_files fast path contract — do not consult
501
+ // .gitignore, so a .gitignored-but-present file is still discoverable.
502
+ // Noise dirs stay excluded via DEFAULT_IGNORE_GLOBS below.
503
+ // Shared rg flags for both enumeration passes below.
504
+ const baseRgArgs = ['--files', '--no-ignore'];
505
+ if (hidden) baseRgArgs.push('--hidden');
506
+ if (depth != null) baseRgArgs.push('--max-depth', String(depth));
507
+ // Noise-exclusion globs are kept SEPARATE and always appended LAST (after
508
+ // any positive --iglob). ripgrep's "last matching glob wins" rule means a
509
+ // positive include placed after these negations would re-admit e.g.
510
+ // `.git/<query>` — so the exclusions must trail the narrowed include.
511
+ const ignoreGlobs = [];
398
512
  if (!includeNoise) {
399
- for (const ex of DEFAULT_IGNORE_GLOBS) rgArgs.push('--glob', ex);
513
+ for (const ex of DEFAULT_IGNORE_GLOBS) ignoreGlobs.push('--glob', ex);
400
514
  }
401
- rgArgs.push('.');
402
- let stdout;
403
- try {
404
- stdout = await runRg(rgArgs, { cwd: fullPath });
405
- } catch (err) {
406
- return `Error: ${normalizeErrorMessage(err instanceof Error ? err.message : String(err))}`;
407
- }
408
- const items = String(stdout)
515
+ // The narrowed pass must treat `query` as a LITERAL filename substring, not
516
+ // a glob. Wrap every globset metacharacter in a single-char character class
517
+ // (`[` → `[[]`, `*` → `[*]`, …): character-class quoting is the only form
518
+ // globset honors on Windows, where a backslash-escape (`\*`) is read as a
519
+ // literal path separator and does NOT escape. So a query like "[slug].tsx"
520
+ // still produces the intended `*[[]slug[]].tsx*` include instead of a
521
+ // character-class that matches one of s/l/u/g.
522
+ const escapeGlobLiteral = (s) => s.replace(/[*?[\]{}]/g, (c) => `[${c}]`);
523
+ // Test-only seam: allow a caller to inject a runRg stand-in (e.g. to
524
+ // simulate a truncated broad pass) without touching the production path.
525
+ // Never set on the real tool-execution options object.
526
+ const runRgImpl = (options && typeof options.__runRg === 'function') ? options.__runRg : runRg;
527
+ const parseRgFiles = (stdout) => String(stdout)
409
528
  .split('\n')
410
529
  // Strip only the trailing CR from rg's line split — do NOT trim, or a
411
530
  // filename with leading/trailing spaces would be corrupted.
412
531
  .map((p) => (p.endsWith('\r') ? p.slice(0, -1) : p))
413
532
  .filter((p) => p.length > 0)
414
- .map((p) => ({ path: normalizeOutputPath(p.replace(/^\.[/\\]/, '')) }));
533
+ .map((p) => normalizeOutputPath(p.replace(/^\.[/\\]/, '')));
534
+ // Broad enumeration: every file under the scope, ranked by fuzzy score.
535
+ // Subject to rg's 20MB/20s cap — an exact-name hit deep in a huge tree can
536
+ // be dropped by cap lottery, so it is backstopped by the narrowed pass.
537
+ // Shared across queries/concurrent callers via the broad-enumeration cache
538
+ // (keyed on root+hidden+depth+includeNoise, i.e. exactly this pass's args).
539
+ let broadEnum;
540
+ try {
541
+ broadEnum = await getBroadEnumeration({
542
+ root: normalizeOutputPath(fullPath),
543
+ hidden, depth, includeNoise,
544
+ rgArgs: [...baseRgArgs, ...ignoreGlobs, '.'],
545
+ cwd: fullPath,
546
+ runRgImpl,
547
+ });
548
+ } catch (err) {
549
+ return `Error: ${normalizeErrorMessage(err instanceof Error ? err.message : String(err))}`;
550
+ }
551
+ const rgTruncated = broadEnum.truncated;
552
+ const rgPartial = broadEnum.partial;
553
+ // Narrowed enumeration: only files whose NAME contains the query
554
+ // (case-insensitive substring glob). This output is tiny and effectively
555
+ // never truncated, so exact/substring hits are guaranteed to reach ranking
556
+ // regardless of whether the broad pass was cut at the cap. Best-effort:
557
+ // failures here never fail the tool — the broad pass still stands.
558
+ let narrowPaths = [];
559
+ try {
560
+ // A positive --iglob whitelist makes ripgrep re-admit paths its own
561
+ // `!**/<noise>/**` negations would otherwise exclude (the whitelist
562
+ // wins regardless of glob order), so noise dirs are pruned in JS here
563
+ // instead — matching the broad pass's effective exclusion set.
564
+ const narrowStdout = await runRgImpl([...baseRgArgs, '--iglob', `*${escapeGlobLiteral(query)}*`, '.'], { cwd: fullPath });
565
+ narrowPaths = parseRgFiles(narrowStdout).filter((p) =>
566
+ includeNoise || !p.split('/').some((seg) => NOISE_DIR_NAMES.has(seg)));
567
+ } catch { /* best-effort backstop; broad pass already collected */ }
568
+ // Merge broad + narrowed, deduplicating by path (broad order preserved,
569
+ // narrowed-only exact-name candidates appended).
570
+ const seen = new Set();
571
+ const items = [];
572
+ for (const p of broadEnum.files) {
573
+ if (seen.has(p)) continue;
574
+ seen.add(p);
575
+ items.push({ path: p });
576
+ }
577
+ for (const p of narrowPaths) {
578
+ if (seen.has(p)) continue;
579
+ seen.add(p);
580
+ items.push({ path: p });
581
+ }
415
582
  const rankLimit = headLimit > 0 ? headLimit + 1 : headLimit;
416
583
  const rankedRaw = fuzzyRank(query, items, rankLimit);
417
584
  const hasMore = headLimit > 0 && rankedRaw.length > headLimit;
418
585
  const ranked = hasMore ? rankedRaw.slice(0, headLimit) : rankedRaw;
419
- if (ranked.length === 0) return `(no fuzzy match for "${query}")`;
420
- const out = ranked.map((r) => r.item.path).join('\n');
421
- const result = hasMore
422
- ? `${out}\n... (top ${headLimit}; raise head_limit for more)`
423
- : out;
424
- cacheSet(cacheKey, result, { scopes: [fullPath] });
586
+ // Build output lines uniformly for the hit and no-match cases so a
587
+ // truncated/partial broad pass ALWAYS surfaces its warning — otherwise a
588
+ // cut-off enumeration that happened to drop the sole match would silently
589
+ // report "(no fuzzy match …)" as if the tree were exhaustively searched.
590
+ const noMatch = ranked.length === 0;
591
+ const lines = noMatch ? [`(no fuzzy match for "${query}")`] : ranked.map((r) => r.item.path);
592
+ if (!noMatch && hasMore) lines.push(`... (top ${headLimit}; raise head_limit for more)`);
593
+ if (rgTruncated) lines.push('... [warning] rg stdout truncated at 20MB cap; broad ranking incomplete (exact-name hits still merged)');
594
+ if (rgPartial && !rgTruncated) lines.push('... [warning] rg exit 2 (partial results); broad ranking may be incomplete');
595
+ const result = lines.join('\n');
596
+ // Do not cache a truncated/partial enumeration — the broad ranking is
597
+ // known-incomplete, so a later call with a larger head_limit must re-run.
598
+ // A no-match result is also left uncached (mirrors the prior early return).
599
+ if (!noMatch && !rgTruncated && !rgPartial) {
600
+ cacheSet(cacheKey, result, { scopes: [fullPath] });
601
+ }
425
602
  if (typeof options?.onProgress === 'function') {
426
603
  try { options.onProgress(`${ranked.length} candidates`); } catch { /* best-effort */ }
427
604
  }
@@ -563,14 +740,30 @@ export async function executeFindFilesTool(args, workDir, options = {}) {
563
740
  // pre-filter matches the JS matcher; explicit globs pass through.
564
741
  if (namePattern) rgArgs.push('--iglob', nameIsGlob ? namePattern : `*${namePattern}*`);
565
742
  rgArgs.push('.');
566
- const stdout = await runRg(rgArgs, { cwd: fullPath });
567
- rgStdoutTruncated = Boolean(stdout && typeof stdout === 'object' && stdout.truncated);
568
- rgStdoutPartial = Boolean(stdout && typeof stdout === 'object' && stdout.partial);
743
+ // No `name` filter → this is the pure broad sweep (rgArgs match the
744
+ // fuzzy-find broad pass), so share it via the enumeration cache.
745
+ // With a `name` filter the sweep is narrowed by --iglob and must
746
+ // NOT hit the broad cache — run it directly.
747
+ let relPaths;
748
+ if (!namePattern) {
749
+ const enumRes = await getBroadEnumeration({
750
+ root: normalizeOutputPath(fullPath),
751
+ hidden, depth, includeNoise,
752
+ rgArgs, cwd: fullPath,
753
+ });
754
+ rgStdoutTruncated = enumRes.truncated;
755
+ rgStdoutPartial = enumRes.partial;
756
+ relPaths = enumRes.files;
757
+ } else {
758
+ const stdout = await runRg(rgArgs, { cwd: fullPath });
759
+ rgStdoutTruncated = Boolean(stdout && typeof stdout === 'object' && stdout.truncated);
760
+ rgStdoutPartial = Boolean(stdout && typeof stdout === 'object' && stdout.partial);
761
+ relPaths = parseRgFileList(stdout);
762
+ }
569
763
  const candidates = [];
570
- for (const line of String(stdout).split('\n')) {
571
- const trimmed = line.trim();
572
- if (!trimmed) continue;
573
- const candidate = resolveAgainstCwd(normalizeInputPath(trimmed), fullPath);
764
+ for (const rel of relPaths) {
765
+ if (!rel) continue;
766
+ const candidate = resolveAgainstCwd(normalizeInputPath(rel), fullPath);
574
767
  if (!matchesFindNamePattern(basename(candidate), candidate)) continue;
575
768
  candidates.push(candidate);
576
769
  if (candidates.length >= FIND_ABSOLUTE_CAP) {
@@ -1,6 +1,7 @@
1
1
  import { statSync } from 'fs';
2
2
  import { assertPathReachable, assertPathsReachable } from './fs-reachability.mjs';
3
3
  import { isAbsolute, resolve } from 'path';
4
+ import { WRAPPER_NAMES } from '../shell-policy.mjs';
4
5
  import {
5
6
  cwdRelativePath,
6
7
  normalizeInputPath,
@@ -651,3 +652,63 @@ export function foregroundLongCommandHint(command, timeoutMs, args = {}) {
651
652
  if (!longTimeout && !watchLike && !longSleep) return '';
652
653
  return 'Error: long foreground command detected.';
653
654
  }
655
+
656
+ // Commands that must NOT be promoted to background on a foreground timeout —
657
+ // they run in the foreground and are killed at their deadline instead. Mirrors
658
+ // the reference CLI DISALLOWED_AUTO_BACKGROUND_COMMANDS: `sleep` on posix,
659
+ // `start-sleep`/`sleep` (the PS built-in alias) on PowerShell. A `sleep`/
660
+ // `Start-Sleep` the caller wants to survive must be launched with
661
+ // run_in_background/async explicitly.
662
+ const _DISALLOWED_AUTO_BACKGROUND_POSIX = ['sleep'];
663
+ const _DISALLOWED_AUTO_BACKGROUND_PS = ['start-sleep', 'sleep'];
664
+
665
+ // Whether one shell segment reaches a disallowed sleep-like command. Strips a
666
+ // leading subshell/paren/brace/`&`, then walks tokens skipping VAR=val and
667
+ // option flags (quotes stripped so `'Start-Sleep'` resolves). A disallowed base
668
+ // name found anywhere reachable blocks promotion. Once a command-runner wrapper
669
+ // (env/sudo/timeout/nice/…, shell-policy.mjs's WRAPPER_NAMES) has been seen we
670
+ // do NOT model that wrapper's flag arity — we keep scanning EVERY later bare
671
+ // token for a hidden `sleep`, so `sudo -u nobody sleep 5` / `setpriv --reuid
672
+ // user sleep` are caught (erring toward NOT promoting). Without a wrapper the
673
+ // first real command token decides the segment.
674
+ function _segmentDisallowed(segment, disallowed) {
675
+ const stripped = String(segment || '').replace(/^[\s(){}&]+/, '');
676
+ const tokens = stripped.split(/\s+/).filter(Boolean);
677
+ let sawWrapper = false;
678
+ for (let tok of tokens) {
679
+ tok = tok.replace(/^['"]+|['"]+$/g, '');
680
+ if (!tok) continue;
681
+ if (/^[A-Za-z_]\w*=/.test(tok)) continue; // VAR=val assignment
682
+ if (tok.startsWith('-')) continue; // option flag
683
+ const stem = tok.toLowerCase().replace(/^.*[\\/]/, '').replace(/\.exe$/, '');
684
+ if (disallowed.includes(stem)) return true; // sleep reachable here
685
+ if (WRAPPER_NAMES.has(stem)) { sawWrapper = true; continue; }
686
+ if (/^\d+(?:\.\d+)?[smhd]?$/i.test(tok)) continue; // numeric/duration arg
687
+ // First real command token. Without a preceding wrapper it IS the base
688
+ // command and it wasn't disallowed → segment is safe. With a wrapper,
689
+ // this may be a wrapper value arg (`sudo -u nobody …`); keep scanning
690
+ // for a hidden sleep instead of stopping here.
691
+ if (!sawWrapper) return false;
692
+ }
693
+ return false;
694
+ }
695
+
696
+ // Whether a still-running foreground one-shot may be promoted to a background
697
+ // job when it hits its timeout (CC isAutobackgroundingAllowed analogue).
698
+ // Scans EVERY segment of a &&/||/;/|/& chain and, per segment, peels wrappers
699
+ // before reading the base command — so a sleep-like hidden behind a chain
700
+ // (`cd x && sleep 5`) or a runner wrapper (`timeout 5 sleep`, `env sleep`) is
701
+ // still caught. Errs toward NOT promoting: any disallowed base command in any
702
+ // segment blocks promotion. shellType 'powershell' uses the PS disallow list.
703
+ export function isAutobackgroundingAllowed(command, shellType) {
704
+ const cmd = String(command || '').trim();
705
+ if (!cmd) return true;
706
+ const disallowed = shellType === 'powershell'
707
+ ? _DISALLOWED_AUTO_BACKGROUND_PS
708
+ : _DISALLOWED_AUTO_BACKGROUND_POSIX;
709
+ const segments = cmd.split(/(?:&&|\|\||[;\n|&])/);
710
+ for (const seg of segments) {
711
+ if (_segmentDisallowed(seg, disallowed)) return false;
712
+ }
713
+ return true;
714
+ }
@@ -402,6 +402,11 @@ export function startBackgroundShellJob({ command, timeoutMs, workDir, mergeStde
402
402
  // watcher never keeps the host process alive.
403
403
  const SHELL_JOB_WATCH_POLL_MS = 2000;
404
404
  const SHELL_JOB_WATCH_GRACE_MS = 5000;
405
+ // 32-bit timer ceiling: setTimeout delays above 2^31-1 wrap to a tiny value
406
+ // and fire immediately. The resolved timeout is already clamped at parse time
407
+ // (bash-tool.mjs TIMER_MAX_MS), but sites that add grace to it can still exceed
408
+ // the ceiling, so clamp the summed delay here too.
409
+ const TIMER_MAX_MS = 2_147_483_647;
405
410
  // Registry of armed background-job watchers keyed by jobId. task wait
406
411
  // and `kill` actions already hold the completed outcome, so they cancel the
407
412
  // armed watcher here to prevent a double-notify when its next poll fires.
@@ -653,11 +658,16 @@ export function watchBackgroundShellJob(jobId, notifyCtx) {
653
658
  } catch { watcher = null; }
654
659
  pollTimer = setInterval(() => checkDone('poll'), SHELL_JOB_WATCH_POLL_MS);
655
660
  if (typeof pollTimer.unref === 'function') pollTimer.unref();
656
- const startedAtMs = Date.parse(readShellJobDetail(jobId)?.startedAt || '') || Date.now();
657
661
  const timeoutMs = Number(readShellJobDetail(jobId)?.timeoutMs || 0);
658
- const hardStopMs = Math.max(0, (startedAtMs + timeoutMs + SHELL_JOB_WATCH_GRACE_MS) - Date.now());
659
- hardStopTimer = setTimeout(() => fire('timeout'), hardStopMs);
660
- if (typeof hardStopTimer.unref === 'function') hardStopTimer.unref();
662
+ // Only arm the hard-stop when a timeout is enforced. timeoutMs<=0 means
663
+ // unlimited: arming it would fire ~grace ms after arm and mark the job
664
+ // failed. fs.watch + poll remain the completion paths for such jobs.
665
+ if (timeoutMs > 0) {
666
+ const startedAtMs = Date.parse(readShellJobDetail(jobId)?.startedAt || '') || Date.now();
667
+ const hardStopMs = Math.min(TIMER_MAX_MS, Math.max(0, (startedAtMs + timeoutMs + SHELL_JOB_WATCH_GRACE_MS) - Date.now()));
668
+ hardStopTimer = setTimeout(() => fire('timeout'), hardStopMs);
669
+ if (typeof hardStopTimer.unref === 'function') hardStopTimer.unref();
670
+ }
661
671
  } catch (err) {
662
672
  cleanup();
663
673
  try { process.stderr.write(`[shell-jobs] watchBackgroundShellJob arm failed: jobId=${jobId} err=${err?.message ?? String(err)}\n`); } catch { /* ignore */ }
@@ -755,7 +765,10 @@ function _startBackgroundShellJobImpl({ command, timeoutMs, workDir, mergeStderr
755
765
  const stderrPath = shellJobStderrPath(jobId);
756
766
  const exitPath = shellJobExitPath(jobId);
757
767
  const donePath = shellJobDonePath(jobId);
758
- const timeoutSeconds = Math.max(1, Math.ceil(timeoutMs / 1000));
768
+ // timeoutMs <= 0 means unlimited (async omitted default): no kernel
769
+ // `timeout` wrapper, no enforced marker — exec the inner shell directly.
770
+ const enforceTimeout = Number(timeoutMs) > 0;
771
+ const timeoutSeconds = enforceTimeout ? Math.max(1, Math.ceil(timeoutMs / 1000)) : 0;
759
772
  // P2 fix: wrap with POSIX `timeout` so the kernel terminates the process
760
773
  // at deadline even if the JS-side timer is interrupted. --preserve-status
761
774
  // keeps the user command's exit code on success; on timeout the wrapper
@@ -787,7 +800,11 @@ function _startBackgroundShellJobImpl({ command, timeoutMs, workDir, mergeStderr
787
800
  // status for processes that actually exited 0. `rm -- "$0"` removes
788
801
  // the staged wrapper .cmd.sh after donePath is published so a host
789
802
  // crash before this point still leaves the file for the sweep to GC.
790
- const wrapped = `{ if command -v timeout >/dev/null 2>&1; then _to=timeout; elif command -v gtimeout >/dev/null 2>&1; then _to=gtimeout; else _to=; fi; if [ -n "$_to" ]; then touch ${shellQuoteSingle(enforcedPath)}; "$_to" ${timeoutSeconds} ${innerShellQ} ${innerArgsQ} ${userCmdQuoted}; else ${innerShellQ} ${innerArgsQ} ${userCmdQuoted}; fi; rc=$?; printf '%s' "$rc" > ${shellQuoteSingle(exitPath)}; touch ${shellQuoteSingle(donePath)}; rm -- "$0" 2>/dev/null; exit $rc; }`;
803
+ const _innerRun = `${innerShellQ} ${innerArgsQ} ${userCmdQuoted}`;
804
+ const _execPart = enforceTimeout
805
+ ? `if command -v timeout >/dev/null 2>&1; then _to=timeout; elif command -v gtimeout >/dev/null 2>&1; then _to=gtimeout; else _to=; fi; if [ -n "$_to" ]; then touch ${shellQuoteSingle(enforcedPath)}; "$_to" ${timeoutSeconds} ${_innerRun}; else ${_innerRun}; fi`
806
+ : _innerRun;
807
+ const wrapped = `{ ${_execPart}; rc=$?; printf '%s' "$rc" > ${shellQuoteSingle(exitPath)}; touch ${shellQuoteSingle(donePath)}; rm -- "$0" 2>/dev/null; exit $rc; }`;
791
808
  // Stage the wrapped command to a .sh and let the script open its own
792
809
  // output files via `exec > … 2> …`. The parent does NOT pass file
793
810
  // descriptors via stdio inheritance (`stdio: 'ignore'` for all three).
@@ -841,8 +858,13 @@ function _startBackgroundShellJobImpl({ command, timeoutMs, workDir, mergeStderr
841
858
  startedAt: new Date().toISOString(),
842
859
  };
843
860
  writeShellJobDetail(detail);
844
- const timer = setTimeout(() => { refreshShellJob(jobId); }, timeoutMs + 25);
845
- if (typeof timer.unref === 'function') timer.unref();
861
+ // Deadline cleanup poke only when a timeout is enforced; an unlimited
862
+ // (timeoutMs<=0) job has no deadline — completion is observed via the
863
+ // fs.watch/poll watcher and refreshShellJob on task queries.
864
+ if (enforceTimeout) {
865
+ const timer = setTimeout(() => { refreshShellJob(jobId); }, Math.min(TIMER_MAX_MS, timeoutMs + 25));
866
+ if (typeof timer.unref === 'function') timer.unref();
867
+ }
846
868
  return detail;
847
869
  }
848
870
 
@@ -876,7 +898,10 @@ function startBackgroundPowerShellJob({ command, timeoutMs, workDir, mergeStderr
876
898
  `$exitPath = ${psSingleQuote(exitPath)}`,
877
899
  `$donePath = ${psSingleQuote(donePath)}`,
878
900
  `$mergeStderr = ${mergeLiteral}`,
879
- `$timeoutMs = ${Math.max(1, Math.floor(timeoutMs || 0))}`,
901
+ // 0 (or negative) = unlimited: the wrapper's `if ($timeoutMs -gt 0 ...`
902
+ // guard falls through to a plain WaitForExit() with no deadline. Do NOT
903
+ // floor to 1 — that would enforce a 1ms timeout on unlimited jobs.
904
+ `$timeoutMs = ${Math.max(0, Math.floor(timeoutMs || 0))}`,
880
905
  '$code = 1',
881
906
  'try {',
882
907
  // -ExecutionPolicy Bypass: unlike -EncodedCommand, -File is subject to
@@ -986,21 +1011,23 @@ function startBackgroundPowerShellJob({ command, timeoutMs, workDir, mergeStderr
986
1011
  pid: childPid,
987
1012
  mergeStderr,
988
1013
  timeoutMs,
989
- timeoutSeconds: Math.max(1, Math.ceil(timeoutMs / 1000)),
1014
+ timeoutSeconds: Number(timeoutMs) > 0 ? Math.max(1, Math.ceil(timeoutMs / 1000)) : 0,
990
1015
  stdoutPath,
991
1016
  stderrPath: mergeStderr ? stdoutPath : rawStderrPath,
992
1017
  exitPath,
993
1018
  donePath,
994
- // The PS wrapper enforces timeoutMs unconditionally in-wrapper
995
- // (WaitForExit($timeoutMs) → Stop-Process → 124), so the deadline
996
- // invariant always holds for PowerShell jobs.
997
- timeoutEnforced: true,
1019
+ // The PS wrapper enforces the deadline (WaitForExit($timeoutMs)
1020
+ // Stop-Process → 124) only when timeoutMs>0; an unlimited job waits
1021
+ // with no deadline, so don't falsely claim enforcement.
1022
+ timeoutEnforced: Number(timeoutMs) > 0,
998
1023
  // Per-terminal session stamp (see resolveJobOwnerHostPid).
999
1024
  ownerHostPid: resolveJobOwnerHostPid(clientHostPid),
1000
1025
  startedAt: new Date().toISOString(),
1001
1026
  };
1002
1027
  writeShellJobDetail(detail);
1003
- const timer = setTimeout(() => { refreshShellJob(jobId); }, timeoutMs + 25);
1004
- if (typeof timer.unref === 'function') timer.unref();
1028
+ if (Number(timeoutMs) > 0) {
1029
+ const timer = setTimeout(() => { refreshShellJob(jobId); }, Math.min(TIMER_MAX_MS, timeoutMs + 25));
1030
+ if (typeof timer.unref === 'function') timer.unref();
1031
+ }
1005
1032
  return detail;
1006
1033
  }
@@ -180,13 +180,61 @@ async function codeGraph(args, cwd, signal = null, options = {}) {
180
180
  }
181
181
 
182
182
  if (mode === 'dependents') {
183
- if (!rel) throw new Error('code_graph dependents: "file" is required');
184
- if (!node) return _appendSameBasenameHint(`Error: code_graph dependents: file not found in graph: ${normFile || '(missing file)'}`, normFile, graph);
183
+ let depRel = rel;
184
+ let depNorm = normFile;
185
+ let subNote = null;
186
+ // (1) Symbol inference runs ONLY when no `file` arg was supplied at all —
187
+ // an explicit file (even a directory that yields no rel) is never overridden.
188
+ if (!depRel && !normFile) {
189
+ const symCandidates = [
190
+ ...(Array.isArray(args?.symbols) ? args.symbols : []),
191
+ args?.symbol,
192
+ ].map((s) => String(s || '').trim()).filter(Boolean);
193
+ const KNOWN_SRC_EXT = /\.(mjs|cjs|js|jsx|mts|cts|ts|tsx|json|py|go|rb|rs|java|kt|c|h|cc|cpp|hpp|cs|php|swift|scala|sh)$/i;
194
+ // (2) Symbol lookup FIRST — dotted names (e.g. obj.method) resolve here
195
+ // before any path classification.
196
+ for (const s of symCandidates) {
197
+ const hits = _findSymbolHits(graph, s, {});
198
+ const usable = hits.filter((h) => graph.nodes.get(h.rel));
199
+ const pool = usable.length ? usable : hits;
200
+ if (!pool.length) continue;
201
+ // (3) Deterministic pick: defining hit, else first by sorted rel.
202
+ const sorted = [...pool].sort((a, b) => String(a.rel).localeCompare(String(b.rel)));
203
+ const primary = sorted.find((h) => h.declarationLike) || sorted[0];
204
+ depRel = primary.rel; depNorm = primary.rel;
205
+ subNote = `# note: dependents resolved from symbol '${s}' → ${primary.rel}`;
206
+ const others = [...new Set(sorted.map((h) => h.rel))].filter((r) => r !== primary.rel);
207
+ if (others.length) subNote += `\n# note: '${s}' also defined in: ${others.join(', ')}`;
208
+ break;
209
+ }
210
+ // Path-classification only when the value has a slash or a known source
211
+ // extension — never for plain dotted symbol names.
212
+ if (!depRel) {
213
+ const pathLike = symCandidates.find((s) => /[\\/]/.test(s) || KNOWN_SRC_EXT.test(s));
214
+ if (pathLike) {
215
+ const pAbs = isAbsolute(pathLike) ? pathResolve(pathLike) : pathResolve(cwd, pathLike);
216
+ const pRel = _graphRel(pAbs, cwd);
217
+ if (graph.nodes.get(pRel)) {
218
+ depRel = pRel; depNorm = pathLike;
219
+ subNote = `# note: treated symbol '${pathLike}' as file`;
220
+ }
221
+ }
222
+ }
223
+ // (4) Nothing resolved → actionable hint naming the attempted values,
224
+ // with a distinct message when no symbol was supplied at all.
225
+ if (!depRel) {
226
+ throw new Error(symCandidates.length
227
+ ? `code_graph dependents: dependents needs file:<path>; got symbol only (tried: ${symCandidates.join(', ')})`
228
+ : 'code_graph dependents: "file" is required (no file or symbol supplied)');
229
+ }
230
+ }
231
+ const depFileNode = depRel ? graph.nodes.get(depRel) : null;
232
+ if (!depFileNode) return _appendSameBasenameHint(`Error: code_graph dependents: file not found in graph: ${depNorm || '(missing file)'}`, depNorm, graph);
185
233
  const GRAPH_LIST_CAP = 200;
186
- const depsAll = [...(graph.reverse.get(rel) || [])].sort();
234
+ const depsAll = [...(graph.reverse.get(depRel) || [])].sort();
187
235
  if (!depsAll.length) return '(no dependents)';
188
236
  const deps = depsAll.slice(0, GRAPH_LIST_CAP);
189
- const basename = rel.split('/').pop();
237
+ const basename = depRel.split('/').pop();
190
238
  const stem = basename.replace(/\.[^/.]+$/, '');
191
239
  const enriched = deps.map((dep) => {
192
240
  const depNode = graph.nodes.get(dep);
@@ -203,7 +251,8 @@ async function codeGraph(args, cwd, signal = null, options = {}) {
203
251
  }
204
252
  return dep;
205
253
  });
206
- const out = enriched.join('\n');
254
+ const body = enriched.join('\n');
255
+ const out = subNote ? `${subNote}\n${body}` : body;
207
256
  return depsAll.length > deps.length
208
257
  ? `${out}\n[truncated — showing first ${GRAPH_LIST_CAP} of ${depsAll.length} dependents]`
209
258
  : out;