mixdog 0.9.119 → 0.9.121

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.119",
3
+ "version": "0.9.121",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Standalone mixdog coding-agent CLI/TUI workspace.",
@@ -19,7 +19,8 @@
19
19
  project-relative paths and omit optional scopes equal to its root; explicit
20
20
  paths may be outside cwd only for targets outside the project.
21
21
  - Plan the fewest dependent rounds, then the fewest calls. Known state —
22
- anything the task supplied, a tool returned, or a check already proved —
22
+ anything the task supplied, a tool returned (applied patches and envelope
23
+ hints included), or a check already proved —
23
24
  is never re-found, re-derived, or re-verified; a change to its subject
24
25
  re-opens it. Batch calls iff none needs
25
26
  another's output or can change another's inputs/state; otherwise
@@ -37,16 +38,23 @@
37
38
  covers only what returned spans cannot, as an anchored offset/limit
38
39
  window. A conclusive result ends its facet; evidence that determines the
39
40
  answer, edit, or deliverable ends retrieval — patch if needed.
41
+ - Inspect read-only; writable handles, repair, or cleanup only when the
42
+ deliverable requires them, copying what they could destroy. Never clear
43
+ an obstacle or unexpected state by mutating it; unrecoverably lost
44
+ evidence ends its search — report best effort.
40
45
  - Once the edit or deliverable is determined, finish in one assistant turn:
41
46
  before `apply_patch`, obtain every target hunk's exact current content and
42
- anchor from `grep`, `code_graph`, or `read`; never infer patch context from
47
+ anchor from `grep`, `code_graph`, `read`, or a prior successful patch
48
+ envelope; never infer patch context from
43
49
  another file, a sample, or expected text. Then issue `apply_patch` calls
44
50
  serially, never in parallel; use one cohesive call with one file section per
45
51
  target, all patches first, then their one batched verification `shell` in
46
52
  the same turn — the runtime runs it after every patch and only if all
47
53
  succeeded, so verification never needs its own turn. It runs the real
48
54
  required postconditions on every changed file and produced artifact,
49
- never echoing a claim; a postcondition that did not actually run is
55
+ never echoing a claim; changes made through `shell` verify under the same
56
+ one-batch contract — one script proving every postcondition, value-level
57
+ included, never one check per round; a postcondition that did not actually run is
50
58
  unresolved, not passed. Retry only failed envelopes; rerun a failed check only
51
59
  after a change that can alter its outcome — commands alike; else switch
52
60
  route or report it unresolved.
@@ -1,6 +1,7 @@
1
1
  import { getAbortSignalForSession } from '../../session/abort-lookup.mjs';
2
- import { unlinkSync, writeFileSync } from 'node:fs';
3
- import { tmpdir } from 'node:os';
2
+ import { accessSync, constants as fsConstants, readdirSync, unlinkSync, writeFileSync } from 'node:fs';
3
+ import { constants as osConstants, tmpdir } from 'node:os';
4
+ import { delimiter as pathDelimiter } from 'node:path';
4
5
  import { join as pathJoin } from 'node:path';
5
6
  import { isLegitimateShellExit } from '../../session/result-classification.mjs';
6
7
  import { makeToolEnvelope } from '../../session/tool-envelope.mjs';
@@ -246,10 +247,69 @@ export function _shellFailureStatus(result, timeout) {
246
247
  ? `[timeout: ${timeout}ms${signalDetail || ' signal: unknown'}${causeDetail}]${timeoutHint}`
247
248
  : (signal
248
249
  ? `[signal: ${signal}${causeDetail}]`
249
- : (exitCode !== 0 && exitCode !== null ? `[exit code: ${exitCode}]` : '')));
250
+ : (exitCode !== 0 && exitCode !== null ? `[exit code: ${exitCode}]${_exitClassDiagnostic(exitCode, result.stderr)}` : '')));
250
251
  return { signal, exitCode, shellToolFailed, statusDetail };
251
252
  }
252
253
 
254
+ // Deterministic POSIX exit-class facts only (127 not-found, 126 not
255
+ // executable, 128+N signal). Per-command meanings (grep 1 = no match, test
256
+ // runner 1 = failures) stay uninterpreted — that would need a per-command
257
+ // dictionary, which is banned steering. 127 additionally names verified
258
+ // same-prefix executables actually present on PATH (fact statement, no
259
+ // substitution suggestion) so the model skips the "then what exists?" probe.
260
+ const _SIGNAL_NAME_BY_NUMBER = new Map(
261
+ Object.entries(osConstants.signals || {}).map(([name, num]) => [num, name]).reverse(),
262
+ );
263
+ function _missingCommandFrom(stderr) {
264
+ const text = String(stderr || '');
265
+ const m = /(?:^|\n)[^\n]*?(?:line \d+:\s*)?([A-Za-z0-9._+-]+):\s*(?:command )?not found/.exec(text)
266
+ || /The term '([^']+)' is not recognized/.exec(text);
267
+ return m ? m[1] : null;
268
+ }
269
+ function _pathPrefixExecutables(cmd, limit = 5) {
270
+ const needle = String(cmd || '').toLowerCase();
271
+ const hits = [];
272
+ if (!needle) return hits;
273
+ const seenDirs = new Set();
274
+ for (const dir of String(process.env.PATH || '').split(pathDelimiter)) {
275
+ if (!dir || seenDirs.has(dir)) continue;
276
+ seenDirs.add(dir);
277
+ if (seenDirs.size > 64) break;
278
+ let entries;
279
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { continue; }
280
+ for (const ent of entries) {
281
+ if (ent.isDirectory()) continue;
282
+ if (!ent.name.toLowerCase().startsWith(needle)) continue;
283
+ if (process.platform !== 'win32') {
284
+ try { accessSync(pathJoin(dir, ent.name), fsConstants.X_OK); } catch { continue; }
285
+ }
286
+ hits.push(`${ent.name} (${dir.replace(/\\/g, '/')})`);
287
+ if (hits.length >= limit) return hits;
288
+ }
289
+ }
290
+ return hits;
291
+ }
292
+ export function _exitClassDiagnostic(exitCode, stderr) {
293
+ // Not gated on 127: compound chains (`a && b; c`) and pipelines mask the
294
+ // 127 into the chain's final code (observed as exit 1 in 6/28 bench
295
+ // cases), so the stderr fact decides, not the exit code.
296
+ {
297
+ const cmd = _missingCommandFrom(stderr);
298
+ if (cmd) {
299
+ const hits = _pathPrefixExecutables(cmd);
300
+ return hits.length
301
+ ? ` — '${cmd}' is not on PATH; PATH does have: ${hits.join(', ')}`
302
+ : ` — '${cmd}' is not on PATH and no '${cmd}*' executable exists on PATH`;
303
+ }
304
+ }
305
+ if (exitCode === 126) return ' — 126: command found but not executable (permission or format)';
306
+ if (exitCode > 128 && exitCode < 165) {
307
+ const name = _SIGNAL_NAME_BY_NUMBER.get(exitCode - 128);
308
+ if (name) return ` — 128+${exitCode - 128}: terminated by ${name}`;
309
+ }
310
+ return '';
311
+ }
312
+
253
313
  export function _composeShellFailure(statusMarker, errorPrefix, warningBlock, payload) {
254
314
  return `${errorPrefix}${statusMarker}${warningBlock ? `\n${warningBlock}` : ''}\n\n${payload}`;
255
315
  }
@@ -76,6 +76,9 @@ export function findFileByBasename(searchRoot, fullPath, { limit = 3, maxDirs =
76
76
  }
77
77
  }
78
78
  }
79
+ // Queue drained without hitting maxDirs/limit → the walk covered the
80
+ // whole (non-vendor, non-hidden) tree; a miss is conclusive.
81
+ matches.exhaustive = queue.length === 0;
79
82
  return matches;
80
83
  } catch { return []; }
81
84
  }
@@ -153,6 +156,7 @@ export function findDirectoryByBasename(searchRoot, fullPath, { limit = 3, maxDi
153
156
  }
154
157
  }
155
158
  }
159
+ matches.exhaustive = queue.length === 0;
156
160
  return matches;
157
161
  } catch { return []; }
158
162
  }
@@ -67,6 +67,11 @@ const NOT_FOUND_CODES = new Set(['ENOENT', 'ENOTDIR']);
67
67
 
68
68
  const ENOENT_FIND_NUDGE = 'Locate with find on the basename before retrying.';
69
69
 
70
+ // Exhaustive-scan miss: the basename BFS drained its queue (whole non-vendor,
71
+ // non-hidden tree walked) with zero hits, so a follow-up find/list re-probe
72
+ // cannot succeed. Declare the miss conclusive instead of nudging one.
73
+ const ENOENT_ABSENT = ' Nothing same-named elsewhere in the project scan — treat the path as absent (create it if the task requires).';
74
+
70
75
  // Per-invocation memo for the ENOENT recovery fs scans. A single grep/glob
71
76
  // ENOENT surfaces the SAME missing path through tryReadFamilyEnoentRedirect
72
77
  // (resolveUniqueEnoentRedirect) AND buildNotFoundHint (which re-runs
@@ -120,7 +125,7 @@ function spaceJoinedPathHint(requestedPath) {
120
125
 
121
126
  function appendEnoentFindNudge(text = '') {
122
127
  const base = String(text || '');
123
- if (base.includes(ENOENT_FIND_NUDGE)) return base;
128
+ if (base.includes(ENOENT_FIND_NUDGE) || base.includes(ENOENT_ABSENT)) return base;
124
129
  const sep = base.length && !/\s$/.test(base) ? ' ' : '';
125
130
  return `${base}${sep}${ENOENT_FIND_NUDGE}`;
126
131
  }
@@ -191,6 +196,7 @@ export function buildNotFoundHint(workDir, missingPath, actionVerb, errCode = 'E
191
196
  if (dirHits.length === 1) {
192
197
  return ` Not found at this path; the same directory name exists at: "${normalizeOutputPath(dirHits[0])}". ${actionVerb} that path directly.`;
193
198
  }
199
+ const absent = elsewhere.exhaustive === true && dirHits.exhaustive === true ? ENOENT_ABSENT : '';
194
200
  const parentRel = nearestExistingParentRel(workDir, missingPath);
195
201
  if (parentRel) {
196
202
  const resolvedParent = parentRel === '.' ? workDir : resolveAgainstCwd(parentRel, workDir);
@@ -200,11 +206,12 @@ export function buildNotFoundHint(workDir, missingPath, actionVerb, errCode = 'E
200
206
  .slice(0, 3);
201
207
  if (siblings.length) {
202
208
  const shown = parentRel === '.' ? '.' : normalizeOutputPath(parentRel);
203
- return ` Not found; under "${shown}" try: ${siblings.map((n) => `"${n}"`).join(', ')}.`;
209
+ return ` Not found; under "${shown}" try: ${siblings.map((n) => `"${n}"`).join(', ')}.${absent}`;
204
210
  }
205
211
  }
212
+ return absent;
206
213
  }
207
- return '';
214
+ return elsewhere.exhaustive === true ? ENOENT_ABSENT : '';
208
215
  }
209
216
 
210
217
  export function relativePathPrefix(pathPrefix, workDir) {
@@ -241,6 +241,17 @@ export function getBackgroundTask(taskId, options = {}) {
241
241
  return task;
242
242
  }
243
243
 
244
+ /** CC-parity lifetime signal: a session-owned background task remains live
245
+ * work even while the interactive turn is idle and every view is detached. */
246
+ export function hasActiveBackgroundTasks(options = {}) {
247
+ pruneTasks(options);
248
+ const wanted = clean(options.surface);
249
+ return [...tasks.values()].some((task) =>
250
+ task.status === 'running'
251
+ && (!wanted || task.surface === wanted)
252
+ && taskMatchesScope(task, options, { includeUnattributed: false }));
253
+ }
254
+
244
255
  export function listBackgroundTasks(options = {}) {
245
256
  const { surface } = options;
246
257
  pruneTasks(options);
@@ -457,6 +468,7 @@ export function taskSummary(task) {
457
468
  operation: task.operation,
458
469
  label: task.label,
459
470
  status: task.status,
471
+ notified: task.notified === true,
460
472
  startedAt: task.startedAt,
461
473
  finishedAt: task.finishedAt,
462
474
  error: task.error,
@@ -20,6 +20,7 @@ import {
20
20
  materializePromptSubmission,
21
21
  preparePromptSubmissionForProvider,
22
22
  } from '../runtime/attachments/store.mjs';
23
+ import { hasActiveBackgroundTasks } from '../runtime/shared/background-tasks.mjs';
23
24
 
24
25
  const MAX_CLONE_DEPTH = 24;
25
26
  const requireDesktopService = createRequire(import.meta.url);
@@ -246,6 +247,12 @@ export function createSessionService({
246
247
  }
247
248
 
248
249
  function sessionBusy(entry) {
250
+ const sessionId = currentSessionId(entry);
251
+ // CC parity: detached views do not make their background commands
252
+ // disposable. Keep the owner runtime (and daemon self-shutdown guard) live
253
+ // until the task reaches a terminal state and its completion can be
254
+ // delivered back into this session.
255
+ if (sessionId && hasActiveBackgroundTasks({ callerSessionId: sessionId })) return true;
249
256
  if (typeof entry?.busy === 'boolean') return entry.busy;
250
257
  try {
251
258
  return updateEntryBusy(entry, entry.runtime.getState?.() || {});
@@ -256,6 +263,14 @@ export function createSessionService({
256
263
  }
257
264
  }
258
265
 
266
+ function liveBusyCount() {
267
+ let count = 0;
268
+ for (const entry of sessions) {
269
+ if (sessionBusy(entry)) count += 1;
270
+ }
271
+ return count;
272
+ }
273
+
259
274
  function startEvictionSweep() {
260
275
  if (evictTimer || closed) return;
261
276
  evictTimer = setInterval(() => {
@@ -1241,8 +1256,9 @@ export function createSessionService({
1241
1256
  configureSession, stop, releaseClient,
1242
1257
  get size() { return sessions.size; },
1243
1258
  /** Live work the daemon must not abandon (self-shutdown guard). */
1244
- get busyCount() { return busyEntries; },
1259
+ get busyCount() { return liveBusyCount(); },
1245
1260
  get status() {
1261
+ const busy = liveBusyCount();
1246
1262
  let watched = 0;
1247
1263
  let retained = 0;
1248
1264
  let projected = 0;
@@ -1253,7 +1269,7 @@ export function createSessionService({
1253
1269
  }
1254
1270
  return {
1255
1271
  live: sessions.size,
1256
- busy: busyEntries,
1272
+ busy,
1257
1273
  watched,
1258
1274
  retained,
1259
1275
  projected,