mixdog 1.1.0 → 1.1.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 (174) hide show
  1. package/README.md +13 -6
  2. package/package.json +1 -2
  3. package/scripts/latest-guard.mjs +63 -0
  4. package/scripts/lib/run-node-tests.mjs +103 -27
  5. package/scripts/lib/test-failure-summary.mjs +7 -2
  6. package/scripts/merge-mac-update-feed.mjs +107 -0
  7. package/scripts/mobile-release.mjs +210 -0
  8. package/scripts/post-release-check.mjs +353 -0
  9. package/scripts/publish-asset-release.mjs +43 -0
  10. package/scripts/release-guard.mjs +63 -0
  11. package/scripts/release-paths.mjs +39 -2
  12. package/scripts/test.mjs +29 -7
  13. package/src/defaults/shipped-history.json +2 -0
  14. package/src/defaults/skills/computer-use/SKILL.md +1 -1
  15. package/src/defaults/skills/setup/references/actions.md +1 -0
  16. package/src/headless-role.mjs +2 -2
  17. package/src/lib/keychain-cjs.cjs +582 -17
  18. package/src/lib/rules-builder.cjs +0 -23
  19. package/src/rules/lead/LEAD.md +2 -2
  20. package/src/runtime/agent/orchestrator/config-storage.mjs +32 -4
  21. package/src/runtime/agent/orchestrator/config.mjs +18 -0
  22. package/src/runtime/agent/orchestrator/context/instruction-files.mjs +130 -0
  23. package/src/runtime/agent/orchestrator/context/role-instructions.mjs +4 -0
  24. package/src/runtime/agent/orchestrator/providers/lib/oauth-token-utils.mjs +11 -0
  25. package/src/runtime/agent/orchestrator/providers/lib/open-login-url.mjs +9 -0
  26. package/src/runtime/agent/orchestrator/providers/mimo-oauth-login.mjs +254 -0
  27. package/src/runtime/agent/orchestrator/providers/mimo-oauth-profile.mjs +218 -0
  28. package/src/runtime/agent/orchestrator/providers/mimo-oauth-tokens.mjs +239 -0
  29. package/src/runtime/agent/orchestrator/providers/mimo-oauth.mjs +233 -0
  30. package/src/runtime/agent/orchestrator/providers/muse-code-oauth-login.mjs +247 -0
  31. package/src/runtime/agent/orchestrator/providers/muse-code-oauth-tokens.mjs +225 -0
  32. package/src/runtime/agent/orchestrator/providers/muse-code-oauth.mjs +542 -0
  33. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +25 -0
  34. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +4 -1
  35. package/src/runtime/agent/orchestrator/providers/openai-codex-model.mjs +13 -0
  36. package/src/runtime/agent/orchestrator/providers/openai-compat-responses.mjs +14 -6
  37. package/src/runtime/agent/orchestrator/providers/openai-oauth-catalog-store.mjs +99 -0
  38. package/src/runtime/agent/orchestrator/providers/openai-oauth-catalog.mjs +4 -8
  39. package/src/runtime/agent/orchestrator/providers/openai-oauth-login.mjs +2 -7
  40. package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +8 -0
  41. package/src/runtime/agent/orchestrator/providers/registry.mjs +4 -0
  42. package/src/runtime/agent/orchestrator/runtime-core/effort.mjs +2 -0
  43. package/src/runtime/agent/orchestrator/runtime-core/goal-tool-defs.mjs +15 -1
  44. package/src/runtime/agent/orchestrator/runtime-core/tool-defs.mjs +1 -5
  45. package/src/runtime/agent/orchestrator/session/context-utils.mjs +12 -14
  46. package/src/runtime/agent/orchestrator/session/loop/send-phase.mjs +11 -1
  47. package/src/runtime/agent/orchestrator/session/loop/tool-call-ids.mjs +90 -0
  48. package/src/runtime/agent/orchestrator/session/loop/tool-exec/before-hook.mjs +19 -11
  49. package/src/runtime/agent/orchestrator/session/loop/tool-phase.mjs +1 -0
  50. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +28 -0
  51. package/src/runtime/agent/orchestrator/session/manager/session-prompt-composition.mjs +8 -0
  52. package/src/runtime/agent/orchestrator/session/manager/session-record.mjs +3 -0
  53. package/src/runtime/agent/orchestrator/session/manager.mjs +1 -5
  54. package/src/runtime/agent/orchestrator/session/read-dedup.mjs +0 -1
  55. package/src/runtime/agent/orchestrator/session/store-agent-worker-rows.mjs +3 -0
  56. package/src/runtime/agent/orchestrator/session/tool-batch/finalize.mjs +2 -0
  57. package/src/runtime/agent/orchestrator/session/transcript-restore/restore.mjs +2 -0
  58. package/src/runtime/agent/orchestrator/session/transcript-restore/tool-result-status.mjs +23 -0
  59. package/src/runtime/agent/orchestrator/session/turn-effort.mjs +18 -0
  60. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +11 -8
  61. package/src/runtime/agent/orchestrator/tools/builtin/atomic-write.mjs +9 -4
  62. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/bashism-rewrite.mjs +245 -0
  63. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/command-preflight.mjs +13 -1
  64. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/completed-result.mjs +3 -1
  65. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/invocation-plan.mjs +2 -2
  66. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/result-format.mjs +49 -0
  67. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +3 -3
  68. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +9 -7
  69. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +61 -8
  70. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +28 -3
  71. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +73 -1
  72. package/src/runtime/agent/orchestrator/tools/builtin/read-single-guards.mjs +2 -2
  73. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +3 -1
  74. package/src/runtime/agent/orchestrator/tools/builtin/search-grep-tool.mjs +17 -6
  75. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +23 -5
  76. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +93 -43
  77. package/src/runtime/agent/orchestrator/tools/builtin/task-tool.mjs +32 -6
  78. package/src/runtime/agent/orchestrator/tools/builtin.mjs +9 -2
  79. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +3 -1
  80. package/src/runtime/agent/orchestrator/tools/lib/shell-run-background/promote-job.mjs +12 -15
  81. package/src/runtime/agent/orchestrator/tools/lib/shell-run-background.mjs +2 -2
  82. package/src/runtime/agent/orchestrator/tools/lib/shell-run-state.mjs +4 -2
  83. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +124 -91
  84. package/src/runtime/agent/orchestrator/tools/shell-exec-policy.mjs +19 -4
  85. package/src/runtime/channels/index.mjs +1 -5
  86. package/src/runtime/channels/lib/runtime-paths.mjs +8 -23
  87. package/src/runtime/channels/lib/tool-dispatch.mjs +1 -13
  88. package/src/runtime/channels/lib/worker-ipc.mjs +2 -2
  89. package/src/runtime/channels/lib/worker-main.mjs +4 -34
  90. package/src/runtime/computer-bridge/client.mjs +25 -1
  91. package/src/runtime/computer-bridge/linux-action-capabilities.mjs +84 -0
  92. package/src/runtime/computer-bridge/linux-readiness.d.mts +52 -0
  93. package/src/runtime/computer-bridge/linux-readiness.mjs +411 -0
  94. package/src/runtime/computer-bridge/test-linux-display.mjs +5 -0
  95. package/src/runtime/effort-judge/compact-tokenizer.mjs +13 -8
  96. package/src/runtime/effort-judge/judge-input.mjs +5 -2
  97. package/src/runtime/effort-judge/model-install.mjs +9 -3
  98. package/src/runtime/effort-judge/model-manifest.json +7 -7
  99. package/src/runtime/memory/lib/embedding-worker.mjs +9 -3
  100. package/src/runtime/memory/lib/pg/supervisor.mjs +3 -0
  101. package/src/runtime/shared/config.mjs +50 -2
  102. package/src/runtime/shared/developer-options.mjs +7 -0
  103. package/src/runtime/shared/instruction-files-mode.mjs +9 -0
  104. package/src/runtime/shared/llm/model-catalog.mjs +2 -1
  105. package/src/runtime/shared/llm/openai-daybreak.mjs +79 -0
  106. package/src/runtime/shared/pristine-execution-contract.json +9 -0
  107. package/src/runtime/shared/pristine-execution.mjs +1 -1
  108. package/src/runtime/shared/provider-accounts.mjs +2 -0
  109. package/src/runtime/shared/resource-admission.mjs +10 -3
  110. package/src/runtime/shared/schedule-model-ref.mjs +11 -0
  111. package/src/runtime/shared/semver.d.mts +1 -0
  112. package/src/runtime/shared/semver.mjs +57 -0
  113. package/src/runtime/shared/session-approval-wait.mjs +35 -0
  114. package/src/runtime/shared/shell-display.mjs +4 -2
  115. package/src/runtime/shared/tool-surface.mjs +2 -2
  116. package/src/runtime/shared/turn-worktree-snapshot.mjs +11 -10
  117. package/src/runtime/shared/update-checker.mjs +2 -31
  118. package/src/session-runtime/boot/apis.mjs +5 -0
  119. package/src/session-runtime/boot/begin.mjs +0 -2
  120. package/src/session-runtime/boot/tools.mjs +5 -4
  121. package/src/session-runtime/model-recency.mjs +3 -0
  122. package/src/session-runtime/provider-auth/login.mjs +10 -10
  123. package/src/session-runtime/provider-auth/setup.mjs +22 -2
  124. package/src/session-runtime/provider-models/row-hydration.mjs +12 -4
  125. package/src/session-runtime/provider-readiness.mjs +4 -0
  126. package/src/session-runtime/resource-mcp-api.mjs +7 -3
  127. package/src/session-runtime/runtime-bootstrap.mjs +0 -4
  128. package/src/session-runtime/runtime-facade.mjs +5 -0
  129. package/src/session-runtime/runtime-paths.mjs +0 -1
  130. package/src/session-runtime/schedule-session-run.mjs +3 -1
  131. package/src/session-runtime/services/agent-tool/index-write-queue.mjs +55 -14
  132. package/src/session-runtime/services/agent-tool/job-views/agent-list.mjs +15 -3
  133. package/src/session-runtime/services/agent-tool/job-views/session-progress.mjs +2 -0
  134. package/src/session-runtime/services/agent-tool/worker-index/approval-wait.mjs +97 -0
  135. package/src/session-runtime/services/agent-tool/worker-index/mutation-batch.mjs +4 -2
  136. package/src/session-runtime/services/agent-tool/worker-index/row-liveness.mjs +8 -2
  137. package/src/session-runtime/services/agent-tool/worker-index/row-shape.mjs +3 -0
  138. package/src/session-runtime/services/agent-tool/worker-index/row-store.mjs +58 -2
  139. package/src/session-runtime/services/agent-tool/worker-index.mjs +69 -15
  140. package/src/session-runtime/services/agent-tool/worker-rows.mjs +2 -0
  141. package/src/session-runtime/services/channel-admin.mjs +4 -2
  142. package/src/session-runtime/services/channel-worker.mjs +1 -1
  143. package/src/session-runtime/services/provider-admin.mjs +80 -17
  144. package/src/session-runtime/services/seeds.mjs +7 -10
  145. package/src/session-runtime/services/usage-dashboard-model.mjs +6 -0
  146. package/src/session-runtime/settings-builtin-tools-api.mjs +15 -0
  147. package/src/session-runtime/settings-profile-api.mjs +27 -2
  148. package/src/session-runtime/setup-tool/executor.mjs +5 -0
  149. package/src/session-runtime/setup-tool/tool-defs.mjs +8 -0
  150. package/src/session-runtime/starter-guide-state.mjs +62 -0
  151. package/src/session-runtime/tool-policy-refresh.mjs +3 -0
  152. package/src/session-runtime/webhook-session-run.mjs +3 -1
  153. package/src/session-runtime/workflow-agents-api/onboarding.mjs +3 -2
  154. package/src/standalone/channel-binding.mjs +1 -2
  155. package/src/standalone/channel-transport/call-dispatch.mjs +4 -10
  156. package/src/standalone/daemon-channels-loader.mjs +2 -3
  157. package/src/standalone/local-session-runtime.mjs +2 -78
  158. package/src/standalone/session-protocol.mjs +2 -0
  159. package/src/tui/app/model-options.mjs +13 -1
  160. package/src/tui/app/model-picker/route-selection/fast-parameters.mjs +10 -4
  161. package/src/tui/app/provider-setup/oauth-actions.mjs +5 -0
  162. package/src/tui/dist/index.mjs +84 -34
  163. package/src/tui/session/context-state.mjs +2 -0
  164. package/src/tui/session/live-share/owner.mjs +17 -0
  165. package/src/tui/session/oauth-flows.mjs +2 -0
  166. package/src/tui/session/session-api/extensions.mjs +12 -8
  167. package/src/tui/session/session-api/integrations.mjs +9 -8
  168. package/src/tui/session/session-api/routes.mjs +2 -2
  169. package/src/tui/session/session-api/settings.mjs +1 -0
  170. package/src/tui/session/session-api/shared.mjs +10 -6
  171. package/src/tui/session/tool-card-results/grouped-result.mjs +11 -2
  172. package/src/tui/session/transcript-spill/spill-dir.mjs +22 -6
  173. package/src/tui/spinner-meta.mjs +26 -10
  174. package/src/runtime/channels/tool-defs.mjs +0 -17
@@ -16,7 +16,13 @@ import { dirname } from 'node:path';
16
16
  import { atomicWrite, symlinkWriteTarget } from './atomic-write.mjs';
17
17
  import { fileVersion } from './file-version.mjs';
18
18
  import { assertPathsReachable } from './fs-reachability.mjs';
19
- import { normalizeInputPath, resolveAgainstCwd, normalizeOutputPath, toDisplayPath } from './path-utils.mjs';
19
+ import {
20
+ normalizeInputPath,
21
+ resolveAgainstCwd,
22
+ normalizeOutputPath,
23
+ toDisplayPath,
24
+ urlAsPathMessage,
25
+ } from './path-utils.mjs';
20
26
  import {
21
27
  isUncPath,
22
28
  isWindowsDevicePath,
@@ -123,6 +129,8 @@ function guardRealTarget(fullPath) {
123
129
  async function resolveTargetPath(args, workDir) {
124
130
  const raw = args?.path ?? args?.file_path;
125
131
  if (typeof raw !== 'string' || raw.length === 0) return null;
132
+ const urlMessage = urlAsPathMessage(raw);
133
+ if (urlMessage) return { error: `Error: ${urlMessage}` };
126
134
  const norm = normalizeInputPath(raw);
127
135
  const guardErr = guardWritePath(norm);
128
136
  if (guardErr) return { error: guardErr };
@@ -474,7 +482,21 @@ function readEditTextForDisplay(fullPath, enc) {
474
482
 
475
483
  // ── str_replace: the edit target record + the steps that mutate it ──────────
476
484
 
477
- const SAME_STRINGS_ERROR = 'Error: old_string and new_string are exactly the same';
485
+ const SAME_STRINGS_ERROR =
486
+ 'Error: old_string and new_string are exactly the same — no change needed; the file may already contain it, re-read before retrying';
487
+
488
+ // 1-based line numbers where oldStr occurs in content (first 8), for the
489
+ // non-unique-match error.
490
+ function matchLineHint(content, oldStr) {
491
+ const lines = [];
492
+ let total = 0;
493
+ for (let at = content.indexOf(oldStr); at !== -1; at = content.indexOf(oldStr, at + Math.max(1, oldStr.length))) {
494
+ total += 1;
495
+ if (lines.length < 8) lines.push(content.slice(0, at).split('\n').length);
496
+ }
497
+ if (!lines.length) return '';
498
+ return ` at lines ${lines.join(', ')}${total > lines.length ? ', …' : ''}`;
499
+ }
478
500
 
479
501
  function targetSnapshot(statBefore) {
480
502
  return statBefore ? { exists: true, ...fileVersion(statBefore) } : { exists: false };
@@ -635,7 +657,7 @@ async function applyEditInProcess(edit, oldStr, editPlan, replaceAll) {
635
657
  if (occurrences > 1 && !replaceAll) {
636
658
  const message = `old_string found ${occurrences} times`;
637
659
  return (
638
- `Error: edit failed (${message}; pass replace_all:true or extend old_string)` +
660
+ `Error: edit failed (${message}${matchLineHint(content, oldStr)}; pass replace_all:true or extend old_string)` +
639
661
  formatEditFailureExcerpt(content, oldStr, message)
640
662
  );
641
663
  }
@@ -675,7 +697,10 @@ async function applyEditNative(edit, oldStr, newStr, editPlan, replaceAll) {
675
697
  const message = err?.message || String(err);
676
698
  if (/old_string (?:not found|found \d+ times)/i.test(message)) {
677
699
  const latest = readEditTextForDisplay(fullPath, fileEnc) ?? content;
678
- return `Error: edit failed (${message})${formatEditFailureExcerpt(latest, oldStr, message)}`;
700
+ const hinted = /found \d+ times/i.test(message)
701
+ ? message.replace(/(found \d+ times)/i, `$1${matchLineHint(latest, oldStr)}`)
702
+ : message;
703
+ return `Error: edit failed (${hinted})${formatEditFailureExcerpt(latest, oldStr, message)}`;
679
704
  }
680
705
  throw err;
681
706
  }
@@ -709,10 +734,37 @@ async function requiresInProcessEdit(edit, editPlan) {
709
734
  );
710
735
  }
711
736
 
737
+ // Models trained on other CLIs send `old_str`/`new_str`. An alias is honoured
738
+ // only when the canonical key is absent; both present with different values is
739
+ // ambiguous and rejected.
740
+ const STR_REPLACE_ALIASES = [
741
+ ['old_string', 'old_str'],
742
+ ['new_string', 'new_str'],
743
+ ];
744
+
745
+ function resolveStrReplaceArgs(args) {
746
+ const out = {};
747
+ for (const [canonical, alias] of STR_REPLACE_ALIASES) {
748
+ const hasCanonical = args?.[canonical] !== undefined;
749
+ const hasAlias = args?.[alias] !== undefined;
750
+ if (hasCanonical && hasAlias && args[canonical] !== args[alias]) {
751
+ return {
752
+ error: `Error: edit received both ${canonical} and ${alias} with different values; pass only ${canonical}`,
753
+ };
754
+ }
755
+ out[canonical] = hasCanonical ? args[canonical] : args?.[alias];
756
+ }
757
+ return out;
758
+ }
759
+
712
760
  async function adaptStrReplace(args, workDir, options) {
713
- let oldStr = args?.old_string;
714
- let newStr = args?.new_string;
715
- if (typeof oldStr !== 'string' || typeof newStr !== 'string') return null;
761
+ const resolved = resolveStrReplaceArgs(args);
762
+ if (resolved.error) return resolved.error;
763
+ let oldStr = resolved.old_string;
764
+ let newStr = resolved.new_string;
765
+ if (typeof oldStr !== 'string' || typeof newStr !== 'string') {
766
+ return `Error: edit requires string old_string and new_string (got keys: ${Object.keys(args).join(', ')})`;
767
+ }
716
768
  if (oldStr === newStr) return SAME_STRINGS_ERROR;
717
769
  const replaceAll = args?.replace_all === true || String(args?.replace_all || '').toLowerCase() === 'true';
718
770
  const target = await resolveTargetPath(args, workDir);
@@ -729,7 +781,8 @@ async function adaptStrReplace(args, workDir, options) {
729
781
  if (read.error) return read.error;
730
782
  const edit = { fullPath, workDir, options, ...read };
731
783
  if (oldStr.length === 0) {
732
- if (edit.content.length > 0) return `Error: cannot create ${fullPath}: file already exists and is not empty`;
784
+ if (edit.content.length > 0)
785
+ return `Error: cannot create ${fullPath}: file exists: to replace it entirely pass its current content as old_string (read it first); to change part, pass that part as old_string with the replacement as new_string`;
733
786
  const stale = await commitEdit(edit, {
734
787
  bytes: encodeEditContent(newStr, edit.fileEnc),
735
788
  after: newStr,
@@ -200,10 +200,16 @@ function unwrapQuotedCommand(command) {
200
200
 
201
201
  function parseCommand(rawCommand, workDir) {
202
202
  const command = unwrapQuotedCommand(rawCommand);
203
- if (commandHasShellSyntax(command))
203
+ if (commandHasShellSyntax(command)) {
204
+ // commandHasShellSyntax already skips quoted text; the pipe hint follows
205
+ // only when the rejected command really has an unquoted `|`.
206
+ const unquoted = command.replace(/"(?:\\.|[^"\\])*"|'[^']*'/g, '');
204
207
  throw new Error(
205
- 'git command must not contain shell operators or substitution; multiple commands belong in the command array'
208
+ `git command must not contain shell operators or substitution; multiple commands belong in the command array.${
209
+ unquoted.includes('|') ? ' For | head/tail use output_limit, for | grep use the grep tool' : ''
210
+ }`
206
211
  );
212
+ }
207
213
  const tokens = tokenizeDirectArgv(command);
208
214
  if (!tokens?.length || !/(^|[\\/])git(?:\.exe)?$/i.test(tokens[0])) {
209
215
  throw new Error('command must begin with git');
@@ -802,6 +808,23 @@ function splitChainedGitCommands(command) {
802
808
  // ("status --short") gets it instead of a rejection. Only whole submitted
803
809
  // commands are prefixed; a `&&` piece after the first still has to be a full
804
810
  // git command, so non-git chain segments stay refused.
811
+ // A JSON-stringified string[] ('["git status","git log"]') for an array param.
812
+ // No git command or change id starts with `[`, so this never reinterprets a
813
+ // call that could have worked.
814
+ function parseStringifiedArray(value) {
815
+ if (typeof value !== 'string') return value;
816
+ const text = value.trim();
817
+ if (!text.startsWith('[') || !text.endsWith(']')) return value;
818
+ try {
819
+ const parsed = JSON.parse(text);
820
+ return Array.isArray(parsed) && parsed.length > 0 && parsed.every((item) => typeof item === 'string')
821
+ ? parsed
822
+ : value;
823
+ } catch {
824
+ return value;
825
+ }
826
+ }
827
+
805
828
  function withGitPrefix(command) {
806
829
  if (typeof command !== 'string') return command;
807
830
  const text = String(unwrapQuotedCommand(command)).trim();
@@ -813,10 +836,12 @@ function withGitPrefix(command) {
813
836
  export async function executeGitTool(rawInput, workDir, options = {}) {
814
837
  if (!rawInput || typeof rawInput !== 'object' || Array.isArray(rawInput))
815
838
  return fail('git requires an arguments object');
839
+ const rawCommand = parseStringifiedArray(rawInput.command);
816
840
  const input = {
817
841
  ...rawInput,
818
- command: Array.isArray(rawInput.command) ? rawInput.command.map(withGitPrefix) : withGitPrefix(rawInput.command),
842
+ command: Array.isArray(rawCommand) ? rawCommand.map(withGitPrefix) : withGitPrefix(rawCommand),
819
843
  };
844
+ if (rawInput.change_ids !== undefined) input.change_ids = parseStringifiedArray(rawInput.change_ids);
820
845
  if (rawInput.command === undefined) delete input.command;
821
846
  const action = input.action ?? 'command';
822
847
  if (action === 'stage') {
@@ -1,6 +1,7 @@
1
1
  import { homedir } from 'node:os';
2
- import { isAbsolute, relative, resolve } from 'node:path';
2
+ import { isAbsolute, relative, resolve, sep } from 'node:path';
3
3
  import { realpath } from 'node:fs/promises';
4
+ import { existsSync, realpathSync } from 'node:fs';
4
5
  import { isWSL } from '../../../../shared/wsl.mjs';
5
6
 
6
7
  // Restore the on-disk casing of a path (win32 only). rg relativizes candidate
@@ -48,6 +49,13 @@ function windowsPathToPosixPath(winPath) {
48
49
  return winPath;
49
50
  }
50
51
 
52
+ // http(s):// URLs are not file paths; the generic path guards would reject
53
+ // them with a misleading ADS/trailing-dot message. Returns an error message or null.
54
+ export function urlAsPathMessage(p) {
55
+ if (typeof p !== 'string' || !/^\s*https?:\/\//i.test(p)) return null;
56
+ return 'this is a URL; use web_fetch';
57
+ }
58
+
51
59
  export function normalizeInputPath(p) {
52
60
  if (typeof p !== 'string') return p;
53
61
  // Trim leading/trailing whitespace — LLMs intermittently emit paths with
@@ -68,6 +76,10 @@ export function normalizeInputPath(p) {
68
76
  const looksUnc = out.startsWith('//');
69
77
  if (looksPosixDrive || looksCygdrive || looksWsl || looksUnc) {
70
78
  out = posixPathToWindowsPath(out);
79
+ } else if (/^\/[a-zA-Z]:[\\/]/.test(out)) {
80
+ // `/C:/x/y` or `/C:\x\y`: a stray leading slash before a drive letter.
81
+ // Left as-is, the guard reads `C:` as a component carrying an NTFS ADS colon.
82
+ out = `${out[1]}:\\${out.slice(4).replace(/\//g, '\\')}`;
71
83
  }
72
84
  } else if (isWSL()) {
73
85
  // Reverse direction: a Windows-style drive path from the host maps to
@@ -323,6 +335,66 @@ function compactReadRangeTuple(value) {
323
335
  };
324
336
  }
325
337
 
338
+ const JOINED_PATH_KEYS = { read: ['path', 'file_path'], grep: ['path', 'root'], glob: ['path', 'root'] };
339
+ const JOINED_PATH_LIMIT = 10;
340
+
341
+ // One string that joins several paths with commas/whitespace. A string that
342
+ // is itself an existing path is left alone (so `C:\Program Files\x` works);
343
+ // otherwise, when EVERY piece exists, it becomes a path array. Anything else
344
+ // is left for the normal not-found error (which carries the array hint).
345
+ // A split never widens scope: every piece must resolve strictly INSIDE the
346
+ // session working directory (the project root). `.`, `..`, the directory
347
+ // itself, its ancestors and anything outside it keep the string unsplit and
348
+ // return the array hint instead.
349
+ // Residual ambiguity (accepted): a missing filename that contains spaces or
350
+ // commas, whose pieces all happen to exist as separate in-project paths, is
351
+ // read as those pieces rather than reported missing.
352
+ // Returns an error message when the split is refused or exceeds the batch
353
+ // limit, else null.
354
+ export function splitJoinedPathScopes(toolName, args, workDir) {
355
+ const keys = JOINED_PATH_KEYS[toolName];
356
+ if (!keys || !args || typeof args !== 'object' || Array.isArray(args)) return null;
357
+ const exists = (p) => {
358
+ try {
359
+ return existsSync(resolveAgainstCwd(normalizeInputPath(p), workDir));
360
+ } catch {
361
+ return false;
362
+ }
363
+ };
364
+ // Containment is judged on REAL targets: a symlink/junction inside the
365
+ // directory that points outside it is outside.
366
+ const realOrNull = (p) => {
367
+ try {
368
+ return realpathSync.native(p);
369
+ } catch {
370
+ return null;
371
+ }
372
+ };
373
+ const realRoot = realOrNull(resolve(workDir));
374
+ const strictlyInside = (p) => {
375
+ const piece = normalizeInputPath(p);
376
+ if (piece === '.' || piece === '..' || !realRoot) return false;
377
+ const target = realOrNull(resolveAgainstCwd(piece, workDir));
378
+ if (!target) return false;
379
+ const rel = relative(realRoot, target);
380
+ return Boolean(rel) && rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
381
+ };
382
+ for (const key of keys) {
383
+ const value = args[key];
384
+ if (typeof value !== 'string' || !/[\s,]/.test(value.trim()) || exists(value)) continue;
385
+ const pieces = value.split(/[\s,]+/).filter(Boolean);
386
+ if (pieces.length < 2 || !pieces.every(exists)) continue;
387
+ if (!pieces.every(strictlyInside)) {
388
+ return `Error: ${toolName} arg "${key}" joins several paths in one string, and one is ".", "..", a parent of the working directory or outside it, so it is not split automatically. To pass several paths, send an array (path[]), not a comma- or space-joined string.`;
389
+ }
390
+ if (pieces.length > JOINED_PATH_LIMIT) {
391
+ return `Error: ${toolName} arg "${key}" joins ${pieces.length} paths in one string (${pieces.length} given, max ${JOINED_PATH_LIMIT}); split or pass the parent directory.`;
392
+ }
393
+ args[key] = pieces;
394
+ }
395
+ return null;
396
+ }
397
+
326
398
  export function coerceReadFamilyPathArg(path) {
327
399
  // A path string names exactly one operand. Filesystem contents must not
328
400
  // change its meaning into a batch, repaired JSON, or another filename.
@@ -6,7 +6,7 @@
6
6
  * none of them opens a body.
7
7
  */
8
8
  import * as fsPromises from 'node:fs/promises';
9
- import { buildNotFoundHint, finalizeReadFamilyEnoentTail } from './search-path-diagnostics.mjs';
9
+ import { appendNotFoundCwdNote, buildNotFoundHint, finalizeReadFamilyEnoentTail } from './search-path-diagnostics.mjs';
10
10
 
11
11
  const GUARD_REASON = {
12
12
  // R1: UNC / SMB share (\\server\share, //server/share). Reading these on
@@ -152,7 +152,7 @@ export function readNotFoundResponse(err, { fullPath, filePath, workDir }, helpe
152
152
  const { findSimilarFile, normalizeErrorMessage, normalizeOutputPath } = helpers;
153
153
  const similar = findSimilarFile(fullPath);
154
154
  let hint = similar
155
- ? ` Did you mean "${normalizeOutputPath(similar)}"?`
155
+ ? appendNotFoundCwdNote(` Did you mean "${normalizeOutputPath(similar)}"?`, workDir, err?.code)
156
156
  : buildNotFoundHint(workDir, fullPath, 'Read', err?.code);
157
157
  hint = finalizeReadFamilyEnoentTail(hint, filePath, err?.code);
158
158
  const safeMsg = normalizeErrorMessage(err instanceof Error ? err.message : String(err), workDir);
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import { executeSingleReadTool } from './read-single-tool.mjs';
10
10
  import { readPathStringGuardError } from './read-open.mjs';
11
- import { coerceReadFamilyPathArg } from './path-utils.mjs';
11
+ import { coerceReadFamilyPathArg, urlAsPathMessage } from './path-utils.mjs';
12
12
  import { readIoAdmission } from '../../../../shared/tool-workload-gates.mjs';
13
13
  import { currentToolExecutionOwner } from '../../../../shared/tool-execution-owner.mjs';
14
14
  import { readReachPreflight } from './read-tool/reach-preflight.mjs';
@@ -113,6 +113,8 @@ async function executeReadToolBody(
113
113
  const { normalizeInputPath, normaliseReadLineWindowArgs } = helpers;
114
114
  applyFilePathAlias(args);
115
115
  args.path = coerceReadFamilyPathArg(args.path);
116
+ const urlMessage = urlAsPathMessage(args.path);
117
+ if (urlMessage) return `Error: ${urlMessage}`;
116
118
  // Reachability preflight up front (all shapes) — before
117
119
  // readPathStringGuardError / image stat.
118
120
  // options._skipReachPreflight: set only by the batch dispatcher on its
@@ -7,7 +7,12 @@ import {
7
7
  normalizeOutputPath,
8
8
  trueCasePath,
9
9
  } from './path-utils.mjs';
10
- import { _suggestIndexedPaths, buildNotFoundHint, finalizeReadFamilyEnoentTail } from './search-path-diagnostics.mjs';
10
+ import {
11
+ _suggestIndexedPaths,
12
+ appendNotFoundCwdNote,
13
+ buildNotFoundHintBody,
14
+ finalizeReadFamilyEnoentTail,
15
+ } from './search-path-diagnostics.mjs';
11
16
  import { buildGrepCacheKey, buildGrepRgArgs } from './search-builders.mjs';
12
17
  import { runRg, runRgWindowedLines } from './native-search-runner.mjs';
13
18
  import { markScopedCacheIncomplete, markScopedCacheUnsafe } from '../../session/cache/scoped-cache-outcome.mjs';
@@ -133,9 +138,15 @@ async function runCachedGrepSearch(request) {
133
138
 
134
139
  return await runScopedSearchInFlight(
135
140
  cacheKey,
136
- ({ signal, scopedCacheOutcome }) => runGrepSearch({
137
- ...request, cacheKey, options: { ...options, scopedCacheOutcome },
138
- }, signal),
141
+ ({ signal, scopedCacheOutcome }) =>
142
+ runGrepSearch(
143
+ {
144
+ ...request,
145
+ cacheKey,
146
+ options: { ...options, scopedCacheOutcome },
147
+ },
148
+ signal
149
+ ),
139
150
  { signal: options?.signal || options?.abortSignal || null, scopes: [grepResolvedPath] },
140
151
  options?.scopedCacheOutcome
141
152
  );
@@ -200,9 +211,9 @@ async function runGrepSearch(request, sharedSignal) {
200
211
 
201
212
  async function grepMissingPathResult({ workDir, grepResolvedPath, searchPath, executeChildBuiltinTool }, err) {
202
213
  const msg = `Error: path does not exist: ${normalizeOutputPath(grepResolvedPath)} (${err?.code || 'ENOENT'})`;
203
- let hint = buildNotFoundHint(workDir, grepResolvedPath, 'Search', err?.code, {});
214
+ let hint = buildNotFoundHintBody(workDir, grepResolvedPath, 'Search', err?.code, {});
204
215
  if (!hint) hint = await _suggestIndexedPaths(grepResolvedPath, executeChildBuiltinTool, workDir);
205
- return msg + finalizeReadFamilyEnoentTail(hint, searchPath, err?.code);
216
+ return msg + finalizeReadFamilyEnoentTail(appendNotFoundCwdNote(hint, workDir, err?.code), searchPath, err?.code);
206
217
  }
207
218
 
208
219
  // Per-call search scope on top of the resolved request: the stat, the
@@ -152,10 +152,10 @@ function nearestExistingParentRel(workDir, missingPath) {
152
152
  function spaceJoinedPathHint(requestedPath) {
153
153
  if (typeof requestedPath !== 'string') return '';
154
154
  const trimmed = requestedPath.trim();
155
- if (!/\s/.test(trimmed)) return '';
156
- const segments = trimmed.split(/\s+/).filter(Boolean);
155
+ if (!/[\s,]/.test(trimmed)) return '';
156
+ const segments = trimmed.split(/[\s,]+/).filter(Boolean);
157
157
  if (segments.length < 2) return '';
158
- return ' Pass multiple scopes as path[] array (not a space-joined string).';
158
+ return ' To pass several paths, send an array (path[]), not a comma- or space-joined string.';
159
159
  }
160
160
 
161
161
  function appendEnoentFindNudge(text = '') {
@@ -167,16 +167,34 @@ function appendEnoentFindNudge(text = '') {
167
167
 
168
168
  export function finalizeReadFamilyEnoentTail(hint, requestedPath, errCode = 'ENOENT') {
169
169
  if (!NOT_FOUND_CODES.has(String(errCode || 'ENOENT'))) return String(hint || '');
170
- return appendEnoentFindNudge(String(hint || '') + spaceJoinedPathHint(requestedPath));
170
+ const multiPathHint = spaceJoinedPathHint(requestedPath);
171
+ // A multi-path-looking input is not a basename to locate; the array hint wins.
172
+ if (multiPathHint) return String(hint || '') + multiPathHint;
173
+ return appendEnoentFindNudge(String(hint || ''));
174
+ }
175
+
176
+ // The base the missing relative path was resolved against (a `cd` in an earlier
177
+ // shell call never moves it), appended to every not-found hint, empty or not.
178
+ export function appendNotFoundCwdNote(hint, workDir, errCode = 'ENOENT') {
179
+ if (!workDir || !NOT_FOUND_CODES.has(String(errCode || 'ENOENT'))) return hint;
180
+ return `${hint} (relative paths resolve from ${normalizeOutputPath(workDir)})`;
171
181
  }
172
182
 
173
183
  export function buildNotFoundHint(workDir, missingPath, actionVerb, errCode = 'ENOENT', cache = null) {
184
+ return appendNotFoundCwdNote(
185
+ buildNotFoundHintBody(workDir, missingPath, actionVerb, errCode, cache),
186
+ workDir,
187
+ errCode
188
+ );
189
+ }
190
+
191
+ export function buildNotFoundHintBody(workDir, missingPath, actionVerb, errCode = 'ENOENT', cache = null) {
174
192
  if (!NOT_FOUND_CODES.has(String(errCode || 'ENOENT'))) return '';
175
193
  // `C:tmpsmp` shape: a Windows path whose separators were eaten by JSON
176
194
  // escaping (`"C:\\tmp\\smp"` written as `"C:\tmp\smp"`). Name the cause —
177
195
  // the sibling/basename hints below can never resolve such a path.
178
196
  const rawMissing = String(missingPath || '');
179
- if (/^[A-Za-z]:(?![\\/])[^\\/]/.test(rawMissing.replace(/\\/g, '\\'))) {
197
+ if (/^[A-Za-z]:(?![\\/])[^\\/]/.test(rawMissing)) {
180
198
  return ` The path has a drive letter but no separator after it — the backslashes were probably lost in escaping. Re-issue with forward slashes (e.g. "C:/tmp/smp").`;
181
199
  }
182
200
  if (missingPathIsOutsideProject(workDir, missingPath)) {
@@ -45,10 +45,11 @@ export function buildJobNotFoundMessage(jobId) {
45
45
  return `Error: task not found: ${jobId}`;
46
46
  }
47
47
 
48
+ // Running promoted jobs, for the runtime-wide read-only counts `task list`
49
+ // shows; each leaves when its native task settles (trackShellJobRecord).
50
+ const promotedJobIds = new Set();
48
51
  const backgroundShellJobWatchers = new Map();
49
52
  const jobNotifyCtxByJobId = new Map();
50
- const shellJobResourceLeases = new Map();
51
- const shellJobResourceLeaseSubscriptions = new Map();
52
53
 
53
54
  function demoteBackgroundShellPriority(pid) {
54
55
  if (process.env.MIXDOG_BG_PRIORITY === '0') return;
@@ -72,27 +73,13 @@ function trackShellJobRecord(task, options) {
72
73
  try {
73
74
  unsubscribe?.();
74
75
  } catch {}
76
+ promotedJobIds.delete(jobId);
75
77
  void completeShellJobRecord(jobId, normalizeShellJobDetail(detail));
76
78
  };
77
79
  unsubscribe = subscribeNativeTask(jobId, settle);
78
80
  settle(getNativeTask(jobId) || task);
79
81
  }
80
82
 
81
- function releaseShellJobResourceLease(jobId) {
82
- const unsubscribe = shellJobResourceLeaseSubscriptions.get(jobId);
83
- shellJobResourceLeaseSubscriptions.delete(jobId);
84
- try {
85
- unsubscribe?.();
86
- } catch {}
87
- const lease = shellJobResourceLeases.get(jobId);
88
- if (!lease) return false;
89
- shellJobResourceLeases.delete(jobId);
90
- try {
91
- Promise.resolve(lease.release()).catch(() => {});
92
- } catch {}
93
- return true;
94
- }
95
-
96
83
  /** Native task status → background-task status. A non-zero exit is a COMMAND
97
84
  * result, not a task failure: the identical run in the foreground reports
98
85
  * `[exit code: N]` and stays a success, so crossing the promotion threshold
@@ -262,24 +249,6 @@ export function killShellJob(jobId) {
262
249
  return { ...attachJobInsights(normalizeShellJobDetail(detail)), killed: wasRunning };
263
250
  }
264
251
 
265
- export function attachShellJobResourceLease(jobId, lease) {
266
- if (!jobId || !lease || typeof lease.release !== 'function') return false;
267
- const detail = getNativeTask(jobId);
268
- if (detail?.status !== 'running') {
269
- try {
270
- Promise.resolve(lease.release()).catch(() => {});
271
- } catch {}
272
- return false;
273
- }
274
- releaseShellJobResourceLease(jobId);
275
- shellJobResourceLeases.set(jobId, lease);
276
- const unsubscribe = subscribeNativeTask(jobId, (task) => {
277
- if (task?.status !== 'running') releaseShellJobResourceLease(jobId);
278
- });
279
- shellJobResourceLeaseSubscriptions.set(jobId, unsubscribe);
280
- return true;
281
- }
282
-
283
252
  export function clearShellJobNotifyCtx(jobId) {
284
253
  jobNotifyCtxByJobId.delete(jobId);
285
254
  }
@@ -319,7 +288,6 @@ export function watchBackgroundShellJob(jobId, notifyCtx) {
319
288
  unsubscribe();
320
289
  backgroundShellJobWatchers.delete(jobId);
321
290
  jobNotifyCtxByJobId.delete(jobId);
322
- releaseShellJobResourceLease(jobId);
323
291
  const enriched = attachJobInsights(normalizeShellJobDetail(detail));
324
292
  const completion = buildShellCompletion(jobId, enriched);
325
293
  const completedTask = completeBackgroundTask(jobId, {
@@ -407,10 +375,26 @@ export async function promoteForegroundShellJob({
407
375
  native.stdoutPath = stdoutPath || null;
408
376
  native.stderrPath = stderrPath || null;
409
377
  }
378
+ // Added before tracking: the record's settlement removes it again.
379
+ promotedJobIds.add(native.jobId);
410
380
  trackShellJobRecord(native, { ownerSessionId, clientHostPid, startedAtMs: started });
411
381
  return native;
412
382
  }
413
383
 
384
+ /** Running background shell jobs owned by sessions outside `ownSessionIds`. */
385
+ export function otherSessionBackgroundShellJobCount(ownSessionIds) {
386
+ let count = 0;
387
+ for (const jobId of [...promotedJobIds]) {
388
+ const task = getNativeTask(jobId);
389
+ if (task?.status !== 'running') {
390
+ promotedJobIds.delete(jobId);
391
+ continue;
392
+ }
393
+ if (!ownSessionIds.has(String(task.ownerSessionId || ''))) count += 1;
394
+ }
395
+ return count;
396
+ }
397
+
414
398
  /** Live record for a command still running in the FOREGROUND. Background jobs
415
399
  * publish through trackShellJobRecord and settle into a terminal record; a
416
400
  * foreground command has no task id and no completion notice, so its record
@@ -518,7 +502,10 @@ export async function shutdownShellJobs(_reason = 'runtime-close', { scope = nul
518
502
  jobNotifyCtxByJobId.delete(task.jobId);
519
503
  }
520
504
  const settled = await Promise.all(jobs.map((task) => waitNativeTask(task.jobId, 1_200).catch(() => null)));
521
- for (const task of jobs) releaseShellJobResourceLease(task.jobId);
505
+ // A process still running after the wait is not forgotten: it keeps being
506
+ // stopped until its exit is confirmed.
507
+ const unconfirmed = jobs.filter((_, index) => settled[index]?.status === 'running').map((task) => task.jobId);
508
+ for (const jobId of unconfirmed) retryShutdownSurvivor(jobId, 1);
522
509
  if (scoped) {
523
510
  // The owning session is gone: nothing can query its tasks again, so
524
511
  // drop ALL of its retained state — not only the ones that happened to
@@ -532,16 +519,79 @@ export async function shutdownShellJobs(_reason = 'runtime-close', { scope = nul
532
519
  } catch {}
533
520
  }
534
521
  }
535
- if (!scoped) {
536
- for (const jobId of [...shellJobResourceLeases.keys()]) {
537
- releaseShellJobResourceLease(jobId);
538
- }
539
- jobNotifyCtxByJobId.clear();
540
- }
522
+ if (!scoped) jobNotifyCtxByJobId.clear();
541
523
  return {
542
524
  killed: jobs.length,
543
525
  cancelledJobs: jobs.length,
544
526
  confirmedJobs: settled.filter((task) => task && task.status !== 'running').length,
527
+ unconfirmedJobs: unconfirmed,
545
528
  cancelledWatchers,
546
529
  };
547
530
  }
531
+
532
+ // Shutdown survivors: re-issue the stop every SURVIVOR_RETRY_MS until the
533
+ // native runner confirms the exit, at most SURVIVOR_MAX_ATTEMPTS times, each
534
+ // step logged. A survivor that outlives every attempt stays listed.
535
+ const SURVIVOR_RETRY_MS = 5_000;
536
+ const SURVIVOR_MAX_ATTEMPTS = 10;
537
+ const shutdownSurvivors = new Map();
538
+
539
+ function logShellJobs(message) {
540
+ try {
541
+ process.stderr.write(`[shell-jobs] ${message}\n`);
542
+ } catch {}
543
+ }
544
+
545
+ function dropShutdownSurvivor(jobId) {
546
+ const entry = shutdownSurvivors.get(jobId);
547
+ if (!entry) return;
548
+ shutdownSurvivors.delete(jobId);
549
+ clearTimeout(entry.timer);
550
+ try {
551
+ entry.unsubscribe();
552
+ } catch {}
553
+ }
554
+
555
+ function retryShutdownSurvivor(jobId, attempts) {
556
+ let entry = shutdownSurvivors.get(jobId);
557
+ if (entry) clearTimeout(entry.timer);
558
+ else {
559
+ entry = { attempts, timer: null, unsubscribe: () => {} };
560
+ shutdownSurvivors.set(jobId, entry);
561
+ // The native terminal event confirms the exit at any time — also after
562
+ // the last retry — and drops the entry.
563
+ entry.unsubscribe = subscribeNativeTask(jobId, (task) => {
564
+ if (task?.status === 'running') return;
565
+ dropShutdownSurvivor(jobId);
566
+ logShellJobs(`${jobId} exit confirmed after shutdown`);
567
+ });
568
+ }
569
+ entry.attempts = attempts;
570
+ logShellJobs(`shutdown could not confirm ${jobId} exited (attempt ${attempts}); retrying`);
571
+ entry.timer = setTimeout(() => {
572
+ if (getNativeTask(jobId)?.status !== 'running') {
573
+ dropShutdownSurvivor(jobId);
574
+ logShellJobs(`${jobId} exit confirmed after shutdown`);
575
+ return;
576
+ }
577
+ if (attempts >= SURVIVOR_MAX_ATTEMPTS) {
578
+ entry.timer = null;
579
+ logShellJobs(`giving up stopping ${jobId} after ${attempts} attempts; it is still running`);
580
+ return;
581
+ }
582
+ try {
583
+ cancelNativeTask(jobId);
584
+ } catch {}
585
+ retryShutdownSurvivor(jobId, attempts + 1);
586
+ }, SURVIVOR_RETRY_MS);
587
+ entry.timer.unref?.();
588
+ }
589
+
590
+ /** Shutdown survivors whose termination is not yet confirmed. */
591
+ export function shellShutdownSurvivors() {
592
+ // Reconcile against native state: an exit whose event was missed still ends the entry.
593
+ for (const jobId of [...shutdownSurvivors.keys()]) {
594
+ if (getNativeTask(jobId)?.status !== 'running') dropShutdownSurvivor(jobId);
595
+ }
596
+ return [...shutdownSurvivors].map(([jobId, entry]) => ({ jobId, attempts: entry.attempts }));
597
+ }