mixdog 0.9.74 → 0.9.76

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mixdog",
3
- "version": "0.9.74",
3
+ "version": "0.9.76",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Standalone mixdog coding-agent CLI/TUI workspace.",
@@ -848,7 +848,17 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
848
848
  activeSession.messages = finalized.messages;
849
849
  if (!finalized.responsePreserved) {
850
850
  releasePendingMessages(sessionId, _turnPendingEntries);
851
- activeSession.sessionStartMetaInjected = _sessionStartMetaInjectedBeforeTurn;
851
+ if (finalized.userTurnPreserved) {
852
+ // Non-user abort (app quit / engine dispose /
853
+ // watchdog): the just-sent user turn stays in
854
+ // history, so the session-start meta it carries
855
+ // remains consumed, and the opaque provider
856
+ // continuation no longer matches — force full
857
+ // transcript replay on the next send.
858
+ activeSession.providerState = undefined;
859
+ } else {
860
+ activeSession.sessionStartMetaInjected = _sessionStartMetaInjectedBeforeTurn;
861
+ }
852
862
  } else {
853
863
  recordPendingMessageDelivery(activeSession, _turnPendingEntries);
854
864
  // The opaque provider continuation now points at a
@@ -10,6 +10,16 @@ const INTERRUPT_MESSAGE_FOR_TOOL_USE = '[Request interrupted by user for tool us
10
10
  const STREAMING_INTERRUPTED_TOOL_RESULT = 'Interrupted by user';
11
11
  const TOOL_USE_REJECT_RESULT = "The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). STOP what you are doing and wait for the user to tell you how to proceed.";
12
12
 
13
+ // Abort reasons that represent an EXPLICIT user cancellation of this turn.
14
+ // Only these rewind a not-yet-answered user turn out of history — the TUI/
15
+ // desktop restore the prompt into the input box on user cancel, so keeping
16
+ // the message would duplicate it on resubmit. Any other closeReason (engine
17
+ // shutdown `cli-react-exit`, watchdog, idle-sweep, runner-crash, …) must
18
+ // PRESERVE the just-sent user turn: nothing restores the prompt there, and
19
+ // rewinding erases the user's message from the persisted transcript (the
20
+ // exact loss seen when the desktop app quits mid-turn).
21
+ const USER_CANCEL_ABORT_REASONS = new Set(['cli-abort', 'user-cancel', 'turn-abort']);
22
+
13
23
  function assistantToolCallIds(message) {
14
24
  if (!message || message.role !== 'assistant') return [];
15
25
  const ids = [];
@@ -60,9 +70,21 @@ function finalizeInterruptedTurn({
60
70
  const preserveResponse = responseStarted
61
71
  && !isInternalRuntimeNotificationText(currentUserContent);
62
72
  if (!preserveResponse) {
73
+ // Null/unknown reasons keep the legacy rewind (status quo for wrapped
74
+ // aborts without a closeReason enum); named non-user reasons preserve.
75
+ const userCancelled = abortReason == null
76
+ || USER_CANCEL_ABORT_REASONS.has(abortReason);
77
+ if (!userCancelled) {
78
+ return {
79
+ messages,
80
+ responsePreserved: false,
81
+ userTurnPreserved: true,
82
+ };
83
+ }
63
84
  return {
64
85
  messages: rewindProvisionalUserTurn(messages, currentUserContent),
65
86
  responsePreserved: false,
87
+ userTurnPreserved: false,
66
88
  };
67
89
  }
68
90
 
@@ -132,7 +154,7 @@ function finalizeInterruptedTurn({
132
154
  : INTERRUPT_MESSAGE,
133
155
  });
134
156
  }
135
- return { messages: pairedMessages, responsePreserved: true };
157
+ return { messages: pairedMessages, responsePreserved: true, userTurnPreserved: true };
136
158
  }
137
159
 
138
160
  export function createTurnInterruptionTracker() {
@@ -45,7 +45,7 @@ import { markCodeGraphDirtyPaths, drainCodeGraphCache } from './code-graph-state
45
45
  import { maybeRewriteWmicProcessCommand } from './shell-policy.mjs';
46
46
  import { _maybeEncodePowerShellCommand } from './shell-command.mjs';
47
47
  import { _captureTrackedMtimes, _trackedDriftNoteAfter, getDedupedDestructiveWarnings } from './builtin/bash-tool.mjs';
48
- import { scrubLoaderVars, scrubProviderSecrets } from './env-scrub.mjs';
48
+ import { scrubLoaderVars, scrubProviderSecrets, scrubRuntimeRootVars } from './env-scrub.mjs';
49
49
  import { checkExecPolicyMessage } from './bash-policy-scan.mjs';
50
50
  import { startChildGuardian } from '../../../shared/child-guardian.mjs';
51
51
  import { resourceAdmission } from '../../../shared/resource-admission.mjs';
@@ -342,6 +342,8 @@ function buildBashEnv() {
342
342
  scrubProviderSecrets(env);
343
343
  // R11 loader/execution scrub (NODE_OPTIONS, LD_PRELOAD, DYLD_*, …).
344
344
  scrubLoaderVars(env);
345
+ // Runtime-root isolation — see env-scrub.mjs scrubRuntimeRootVars.
346
+ scrubRuntimeRootVars(env);
345
347
  return env;
346
348
  }
347
349
 
@@ -44,7 +44,7 @@ import { normalizeOutputPath } from './path-utils.mjs';
44
44
  import { normalizeErrorMessage } from './path-diagnostics.mjs';
45
45
  import { invalidateBuiltinResultCache } from './cache-layers.mjs';
46
46
  import { resolveOptionalCwd } from './cwd-utils.mjs';
47
- import { scrubLoaderVars, scrubProviderSecrets } from '../env-scrub.mjs';
47
+ import { scrubLoaderVars, scrubProviderSecrets, scrubRuntimeRootVars } from '../env-scrub.mjs';
48
48
  import { resolveSessionCwd, stateFilePath, wrapPowerShellWithCwdProbe, wrapBashWithCwdProbe } from '../shell-state.mjs';
49
49
  import { resourceAdmission } from '../../../../shared/resource-admission.mjs';
50
50
 
@@ -407,6 +407,7 @@ export async function executeBashTool(args, workDir, options = {}) {
407
407
  // R5/R11: same scrub as background/persistent spawn sites (env-scrub.mjs).
408
408
  scrubProviderSecrets(spawnEnv);
409
409
  scrubLoaderVars(spawnEnv);
410
+ scrubRuntimeRootVars(spawnEnv);
410
411
  let wrappedCommand;
411
412
  // PowerShell UTF-8 prefix is PS-only: the Windows Git Bash path
412
413
  // (shellType==='posix') must NOT receive it. Snapshot wrapper stays
@@ -2,7 +2,7 @@
2
2
  import { spawn } from 'child_process';
3
3
  import { existsSync, readFileSync, statSync, unlinkSync, watch as fsWatch, writeFileSync } from 'fs';
4
4
  import { basename } from 'path';
5
- import { scrubLoaderVars, scrubProviderSecrets } from '../env-scrub.mjs';
5
+ import { scrubLoaderVars, scrubProviderSecrets, scrubRuntimeRootVars } from '../env-scrub.mjs';
6
6
  import {
7
7
  normalizeToolNotifyContext,
8
8
  notifyToolCompletion,
@@ -169,7 +169,7 @@ export async function _startBackgroundShellJobImpl({
169
169
  try {
170
170
  child = spawnFn(shell, [wrappedTempPath], {
171
171
  cwd: workDir,
172
- env: scrubLoaderVars(scrubProviderSecrets({ ...spawnEnv })),
172
+ env: scrubRuntimeRootVars(scrubLoaderVars(scrubProviderSecrets({ ...spawnEnv }))),
173
173
  stdio: 'ignore',
174
174
  ...detachedSpawnOpts,
175
175
  });
@@ -398,7 +398,7 @@ async function startBackgroundPowerShellJob({
398
398
  try {
399
399
  child = spawnFn(shell, wrapperArgs, {
400
400
  cwd: workDir,
401
- env: scrubLoaderVars(scrubProviderSecrets({ ...spawnEnv })),
401
+ env: scrubRuntimeRootVars(scrubLoaderVars(scrubProviderSecrets({ ...spawnEnv }))),
402
402
  detached: false,
403
403
  stdio: 'ignore',
404
404
  windowsHide: true,
@@ -98,3 +98,17 @@ export function scrubProviderSecrets(env) {
98
98
  }
99
99
  return env;
100
100
  }
101
+
102
+ // Runtime-root isolation: MIXDOG_ROOT is a runtime-internal alias for THIS
103
+ // process's install/resource root (a packaged desktop host points it at
104
+ // resources/runtime.asar/...). Model-spawned shells must never inherit it —
105
+ // a dev-tree test run inside such a shell would resolve defaults/agents.json
106
+ // and friends from the INSTALLED app instead of the repo under test (plain
107
+ // node can't even read asar paths → ENOENT). Internal worker spawns that need
108
+ // the root re-set it explicitly and are unaffected: only shell spawn sites
109
+ // call this.
110
+ export function scrubRuntimeRootVars(env) {
111
+ if (!env || typeof env !== 'object') return env;
112
+ delete env.MIXDOG_ROOT;
113
+ return env;
114
+ }
@@ -18,7 +18,7 @@ import { join } from 'node:path';
18
18
  import { homedir } from 'node:os';
19
19
  import { randomUUID } from 'node:crypto';
20
20
  import { getPluginData } from '../config.mjs';
21
- import { scrubLoaderVars, scrubProviderSecrets } from './env-scrub.mjs';
21
+ import { scrubLoaderVars, scrubProviderSecrets, scrubRuntimeRootVars } from './env-scrub.mjs';
22
22
 
23
23
  const SNAPSHOT_TIMEOUT_MS = 10_000;
24
24
 
@@ -191,6 +191,8 @@ function _runSnapshot(shellPath, snapshotPath, configFileExists) {
191
191
  // provider/cloud tokens before exposing the env to that script.
192
192
  // Shared with bash-session and shell-jobs via env-scrub.mjs.
193
193
  scrubProviderSecrets(e);
194
+ // Runtime-root isolation — see env-scrub.mjs scrubRuntimeRootVars.
195
+ scrubRuntimeRootVars(e);
194
196
  return e;
195
197
  })(),
196
198
  windowsHide: true,
@@ -23,6 +23,7 @@ const PERIODIC_SNAPSHOT_INTERVAL_MS = SNAPSHOT_INTERVAL_MS
23
23
  - SNAPSHOT_INTERVAL_JITTER_MS;
24
24
  const SNAPSHOT_RATE_LIMIT_MS = 60 * 1000;
25
25
  const SNAPSHOT_LOCK_STALE_MS = 10000;
26
+ const PERIODIC_ATTRIBUTION_FREE_BYTES = 2 * 1024 * 1024 * 1024;
26
27
 
27
28
  let periodicTimer = null;
28
29
  let lastPressureSnapshotAt = 0;
@@ -204,9 +205,20 @@ async function captureMemoryPressureSnapshot(reason = 'memory-pressure') {
204
205
  return appendSnapshot(entry);
205
206
  }
206
207
 
208
+ /**
209
+ * Periodic samples stay cheap, but once host free memory drops below the
210
+ * attribution threshold the sample upgrades to a full top-process snapshot so
211
+ * post-mortem analysis can name the consumer.
212
+ */
207
213
  async function capturePeriodicMemorySnapshot() {
208
214
  if (!enabled()) return false;
209
- return appendSnapshot(baseSnapshot('periodic'));
215
+ const entry = baseSnapshot('periodic');
216
+ const freeMemoryBytes = Number(entry.systemMemory?.freeMemoryBytes);
217
+ if (Number.isFinite(freeMemoryBytes) && freeMemoryBytes < PERIODIC_ATTRIBUTION_FREE_BYTES) {
218
+ const processes = await topProcesses();
219
+ if (processes) entry.topProcesses = processes;
220
+ }
221
+ return appendSnapshot(entry);
210
222
  }
211
223
 
212
224
  /**
@@ -183,7 +183,10 @@ export function useTranscriptScroll({
183
183
  if (Math.abs(target - next) < 0.12) {
184
184
  scrollPositionRef.current = target;
185
185
  setScrollOffset(Math.max(0, Math.round(target)));
186
- followingRef.current = false;
186
+ // Landing at the true bottom must NOT drop an armed follow: the glide
187
+ // was toward the live tail, and clearing the arm here left auto-scroll
188
+ // off even though the user ended exactly where follow should resume.
189
+ if (target > 0) followingRef.current = false;
187
190
  stopSmoothScroll();
188
191
  return;
189
192
  }
@@ -483,7 +486,15 @@ export function useTranscriptScroll({
483
486
  setScrollOffset(anchoredTarget);
484
487
  return;
485
488
  }
486
- const target = Math.max(0, Math.min(maxTarget, scrollTargetRef.current + deltaRows));
489
+ let target = Math.max(0, Math.min(maxTarget, scrollTargetRef.current + deltaRows));
490
+ // Bottom snap: while a stream is appending rows, the reading-anchor effect
491
+ // keeps RAISING the bottom-relative target between wheel events, so a
492
+ // 3-row wheel notch could chase the bottom forever and never reach the
493
+ // exact 0 that re-engages auto-follow. A downward scroll that lands within
494
+ // one notch of the bottom is an unambiguous "go back to the tail" intent —
495
+ // snap it to 0 so re-pinning is deterministic.
496
+ const BOTTOM_SNAP_ROWS = 3;
497
+ if (deltaRows < 0 && target > 0 && target <= BOTTOM_SNAP_ROWS) target = 0;
487
498
  const appliedDelta = target - scrollTargetRef.current;
488
499
  // Before the scroll moves selected rows out of view, snapshot the rows
489
500
  // currently under the selection into the stitch buffer keyed by the
@@ -510,6 +521,10 @@ export function useTranscriptScroll({
510
521
  if (target === 0) {
511
522
  transcriptAnchorRef.current = null;
512
523
  transcriptAnchorDirtyRef.current = false;
524
+ // A deliberate downward return to the exact bottom re-arms follow, so
525
+ // the next stream growth pins immediately instead of racing the anchor
526
+ // effect for a target that may already have drifted positive again.
527
+ if (deltaRows < 0) followingRef.current = true;
513
528
  } else {
514
529
  const geom = transcriptGeomRef.current || {};
515
530
  const prefixRows = geom.prefixRows;
@@ -13940,7 +13940,7 @@ function useTranscriptScroll({
13940
13940
  if (Math.abs(target - next) < 0.12) {
13941
13941
  scrollPositionRef.current = target;
13942
13942
  setScrollOffset(Math.max(0, Math.round(target)));
13943
- followingRef.current = false;
13943
+ if (target > 0) followingRef.current = false;
13944
13944
  stopSmoothScroll();
13945
13945
  return;
13946
13946
  }
@@ -14184,7 +14184,9 @@ function useTranscriptScroll({
14184
14184
  setScrollOffset(anchoredTarget);
14185
14185
  return;
14186
14186
  }
14187
- const target = Math.max(0, Math.min(maxTarget, scrollTargetRef.current + deltaRows));
14187
+ let target = Math.max(0, Math.min(maxTarget, scrollTargetRef.current + deltaRows));
14188
+ const BOTTOM_SNAP_ROWS = 3;
14189
+ if (deltaRows < 0 && target > 0 && target <= BOTTOM_SNAP_ROWS) target = 0;
14188
14190
  const appliedDelta = target - scrollTargetRef.current;
14189
14191
  if (appliedDelta !== 0 && dragRef.current.region === "transcript" && dragRef.current.rect) {
14190
14192
  flushPendingSelectionPaint();
@@ -14196,6 +14198,7 @@ function useTranscriptScroll({
14196
14198
  if (target === 0) {
14197
14199
  transcriptAnchorRef.current = null;
14198
14200
  transcriptAnchorDirtyRef.current = false;
14201
+ if (deltaRows < 0) followingRef.current = true;
14199
14202
  } else {
14200
14203
  const geom = transcriptGeomRef.current || {};
14201
14204
  const prefixRows = geom.prefixRows;