mixdog 0.9.10 → 0.9.12

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 (40) hide show
  1. package/package.json +1 -1
  2. package/scripts/agent-tag-reuse-smoke.mjs +12 -8
  3. package/scripts/bench/cache-probe-tasks.json +8 -0
  4. package/scripts/tool-smoke.mjs +109 -0
  5. package/src/defaults/mixdog-config.template.json +1 -0
  6. package/src/mixdog-session-runtime.mjs +46 -0
  7. package/src/runtime/agent/orchestrator/config.mjs +30 -0
  8. package/src/runtime/agent/orchestrator/context/collect.mjs +160 -0
  9. package/src/runtime/agent/orchestrator/mcp/client.mjs +25 -1
  10. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +24 -9
  11. package/src/runtime/agent/orchestrator/providers/gemini.mjs +18 -2
  12. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +23 -0
  13. package/src/runtime/agent/orchestrator/session/loop/deferred-call-through.mjs +79 -0
  14. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +7 -1
  15. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +9 -2
  16. package/src/runtime/agent/orchestrator/session/manager.mjs +3 -3
  17. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +5 -5
  18. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +1 -22
  19. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +26 -3
  20. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +29 -1
  21. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +17 -16
  22. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +2 -1
  23. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +4 -0
  24. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +30 -4
  25. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +333 -14
  26. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +129 -2
  27. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +58 -18
  28. package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +56 -83
  29. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +26 -56
  30. package/src/runtime/channels/lib/webhook/deliveries.mjs +4 -1
  31. package/src/session-runtime/settings-api.mjs +13 -0
  32. package/src/session-runtime/tool-catalog.mjs +35 -7
  33. package/src/session-runtime/tool-defs.mjs +28 -0
  34. package/src/standalone/agent-tool/render.mjs +2 -0
  35. package/src/standalone/agent-tool.mjs +28 -6
  36. package/src/standalone/memory-runtime-proxy.mjs +85 -21
  37. package/src/tui/App.jsx +20 -1
  38. package/src/tui/app/extension-pickers.mjs +6 -3
  39. package/src/tui/dist/index.mjs +63 -16
  40. package/src/tui/engine.mjs +57 -12
@@ -12,10 +12,20 @@ import { withAdvisoryLocks } from '../builtin/advisory-lock.mjs';
12
12
  import { wrapMutationRouteOutput } from '../mutation-planner.mjs';
13
13
  import { getPluginData } from '../../config.mjs';
14
14
  import { prepareInput, isV4APatchInput, hasUnifiedBareV4AHunk, canFallbackCountedUnified, parseV4APatch, isCompactedPlaceholderPatch } from './parsing.mjs';
15
- import { resolveBasePath, resolveV4AEntryPath, mergeDuplicateParsedModifyEntries, renderParsedUnifiedPatch, rewriteHeaderPaths, preValidateNativeBatch } from './paths.mjs';
15
+ import {
16
+ resolveBasePath,
17
+ resolveV4AEntryPath,
18
+ parsedEntryResolvedPath,
19
+ isResolvedPathOutsideBase,
20
+ assertNoDuplicateParsedModifyTargets,
21
+ mergeDuplicateParsedModifyEntries,
22
+ renderParsedUnifiedPatch,
23
+ rewriteHeaderPaths,
24
+ preValidateNativeBatch,
25
+ } from './paths.mjs';
16
26
  import { ensureNativePatchBinaryAvailable } from './native-server.mjs';
17
27
  import { assertPathReachable } from '../builtin/fs-reachability.mjs';
18
- import { dispatchNativePatch } from './dispatch.mjs';
28
+ import { dispatchNativePatch, dispatchJsPatchEntries } from './dispatch.mjs';
19
29
  import {
20
30
  planV4ARenameSections,
21
31
  applyV4ARenameSections,
@@ -172,6 +182,11 @@ async function apply_patch(args, cwd, options = {}) {
172
182
  return 'Error: patch contained no file sections';
173
183
  }
174
184
  if (!v4aRenameOnly) {
185
+ try {
186
+ assertNoDuplicateParsedModifyTargets(parsed, basePath);
187
+ } catch (err) {
188
+ return `Error: ${err?.message || String(err)}`;
189
+ }
175
190
  const merged = mergeDuplicateParsedModifyEntries(parsed, basePath);
176
191
  if (merged.changed) {
177
192
  parsed = merged.parsed;
@@ -216,25 +231,50 @@ async function apply_patch(args, cwd, options = {}) {
216
231
  if (lines.length === 0) return 'Error: patch contained no applicable file sections';
217
232
  return wrapPatchMutationOutput(`${lines.join('\n')}\n`, mutationPlan, { backend: 'v4a-rename' });
218
233
  }
219
- const nativePatchStr = rewriteHeaderPaths(normalizedPatchStr, headerRewrites);
220
- const nativeResult = await dispatchNativePatch({
221
- entries,
222
- basePath,
223
- nativePatchStr,
224
- fuzz,
225
- rejectPartial,
226
- dryRun,
227
- readStateScope,
228
- signal: abortSignal,
229
- parsed,
230
- });
231
- let combined = nativeResult;
234
+ const insideEntries = entries.filter((entry) => !isResolvedPathOutsideBase(entry.fullPath, basePath));
235
+ const outsideEntries = entries.filter((entry) => isResolvedPathOutsideBase(entry.fullPath, basePath));
236
+ const parsedInside = (parsed || []).filter(
237
+ (entry) => !isResolvedPathOutsideBase(parsedEntryResolvedPath(entry, basePath), basePath),
238
+ );
239
+ const resultParts = [];
240
+ if (outsideEntries.length > 0) {
241
+ const jsResult = await dispatchJsPatchEntries({
242
+ rows: outsideEntries,
243
+ parsed,
244
+ basePath,
245
+ dryRun,
246
+ fuzzy,
247
+ readStateScope,
248
+ });
249
+ if (isPatchErrorText(jsResult)) return wrapPatchMutationOutput(jsResult, mutationPlan, { backend: 'js-patch' });
250
+ resultParts.push(jsResult);
251
+ }
252
+ if (insideEntries.length > 0) {
253
+ const nativePatchStr = rewriteHeaderPaths(renderParsedUnifiedPatch(parsedInside), headerRewrites);
254
+ const nativeResult = await dispatchNativePatch({
255
+ entries: insideEntries,
256
+ basePath,
257
+ nativePatchStr,
258
+ fuzz,
259
+ rejectPartial,
260
+ dryRun,
261
+ readStateScope,
262
+ signal: abortSignal,
263
+ parsed: parsedInside,
264
+ });
265
+ if (isPatchErrorText(nativeResult)) {
266
+ if (resultParts.length > 0) return wrapPatchMutationOutput(nativeResult, mutationPlan, { backend: 'native-patch' });
267
+ return wrapPatchMutationOutput(nativeResult, mutationPlan, { backend: 'native-patch' });
268
+ }
269
+ resultParts.push(nativeResult);
270
+ }
271
+ let combined = resultParts.join('\n');
232
272
  const renameLines = formatV4ARenameSuccessLines(v4aRenameResults);
233
- if (renameLines.length > 0 && !isPatchErrorText(nativeResult)) {
234
- combined = `${renameLines.join('\n')}\n${nativeResult}`;
273
+ if (renameLines.length > 0 && !isPatchErrorText(combined)) {
274
+ combined = `${renameLines.join('\n')}\n${combined}`;
235
275
  }
236
276
  if (!isPatchErrorText(combined) && options?.toolCallId) {
237
- registerApplyPatchUiDiff(options.toolCallId, nativePatchStr);
277
+ registerApplyPatchUiDiff(options.toolCallId, rewriteHeaderPaths(normalizedPatchStr, headerRewrites));
238
278
  }
239
279
  if (!isPatchErrorText(combined) && rejectedV4AHunks.length > 0) {
240
280
  const tail = [
@@ -39,11 +39,17 @@ export function resolveV4AEntryPath(basePath, rawName) {
39
39
  }
40
40
 
41
41
  export function resolveBasePath(cwd, basePath) {
42
- if (!basePath) return cwd;
42
+ if (basePath == null || basePath === '') return cwd;
43
43
  const norm = normalizeInputPath(basePath);
44
44
  return isAbsolute(norm) ? pathResolve(norm) : resolveAgainstCwd(norm, cwd);
45
45
  }
46
46
 
47
+ export function isResolvedPathOutsideBase(fullPath, basePath) {
48
+ const rel = pathRelative(pathResolve(basePath), pathResolve(fullPath)).replace(/\\/g, '/');
49
+ if (!rel || isAbsolute(rel)) return true;
50
+ return rel.split(/[\\/]+/).some((part) => part === '..');
51
+ }
52
+
47
53
  // Categorise the per-file entry. A unified diff can describe:
48
54
  // - modify : both files named, oldFileName exists on disk
49
55
  // - create : oldFileName === /dev/null (or file doesn't exist + hunks start at 0)
@@ -56,6 +62,13 @@ export function classifyEntry(entry) {
56
62
  return 'modify';
57
63
  }
58
64
 
65
+
66
+ export function parsedEntryResolvedPath(entry, basePath) {
67
+ const kind = classifyEntry(entry);
68
+ const headerName = kind === 'create' ? entry.newFileName : entry.oldFileName;
69
+ return resolveEntryPath(basePath, headerName);
70
+ }
71
+
59
72
  function parsedEntryTargetKey(entry, basePath) {
60
73
  if (classifyEntry(entry) !== 'modify') return '';
61
74
  const headerName = entry.oldFileName || entry.newFileName;
@@ -86,6 +99,28 @@ export function mergeDuplicateParsedModifyEntries(parsed, basePath) {
86
99
  return { parsed: out, changed };
87
100
  }
88
101
 
102
+
103
+ export function assertNoDuplicateParsedModifyTargets(parsed, basePath) {
104
+ const seenPaths = new Set();
105
+ for (const entry of parsed || []) {
106
+ if (classifyEntry(entry) !== 'modify') continue;
107
+ const key = parsedEntryTargetKey(entry, basePath);
108
+ if (!key) continue;
109
+ if (seenPaths.has(key)) {
110
+ const headerName = entry.oldFileName || entry.newFileName;
111
+ const display = normalizeOutputPath(stripDiffPrefix(headerName));
112
+ throw new Error(`apply_patch: duplicate target ${display} — patch lists the same path twice.`);
113
+ }
114
+ seenPaths.add(key);
115
+ }
116
+ }
117
+
118
+ function headerRelFromBase(basePath, absNorm) {
119
+ const rel = pathRelative(pathResolve(basePath), pathResolve(absNorm)).replace(/\\/g, '/');
120
+ if (!rel || isAbsolute(rel)) return null;
121
+ return rel;
122
+ }
123
+
89
124
  function unifiedRange(start, lines) {
90
125
  const s = Math.max(0, Number(start) || 0);
91
126
  const n = Math.max(0, Number(lines) || 0);
@@ -120,36 +155,13 @@ export function countHunkChanges(hunks) {
120
155
  return { added, removed };
121
156
  }
122
157
 
123
- // Header-shape pre-validator (lexical only): a missing header, a
124
- // `..` segment, or an absolute path that does not resolve inside the
125
- // basePath returns false. Caller wraps the negative result into a clean
126
- // throw so unsupported headers surface as a clean error instead of
127
- // silently degrading to a JS fallback.
128
- function nativeHeaderSupported(entry, basePath) {
158
+ // Header-shape pre-validator: missing or /dev/null target path only.
159
+ // Lexical `..` and out-of-base absolutes resolve via resolveEntryPath;
160
+ // write permission is enforced at the hook layer, not here.
161
+ function nativeHeaderSupported(entry) {
129
162
  const kind = classifyEntry(entry);
130
163
  const headerName = kind === 'create' ? entry.newFileName : entry.oldFileName;
131
- if (!headerName || DEV_NULL.test(headerName)) return false;
132
- // Reject any `..` segment on EITHER oldFileName or newFileName (every
133
- // non-/dev/null header) — a modify whose newFileName traverses out of
134
- // base must still be refused even when the resolved path lands inside.
135
- for (const which of ['oldFileName', 'newFileName']) {
136
- const raw = entry[which];
137
- if (!raw || DEV_NULL.test(raw)) continue;
138
- const segs = normalizeInputPath(stripDiffPrefix(raw)).split(/[\\/]+/);
139
- if (segs.some((part) => part === '..')) return false;
140
- }
141
- const stripped = stripDiffPrefix(headerName);
142
- const norm = normalizeInputPath(stripped);
143
- if (isAbsolute(norm) || /^[A-Za-z]:[\\/]/.test(norm)) {
144
- if (!basePath) return false;
145
- const absHeader = pathResolve(norm);
146
- const absBase = pathResolve(basePath);
147
- const rel = pathRelative(absBase, absHeader);
148
- if (!rel || rel.startsWith('..') || isAbsolute(rel)) return false;
149
- if (rel.split(/[\\/]+/).some((part) => part === '..')) return false;
150
- return true;
151
- }
152
- return true;
164
+ return !!(headerName && !DEV_NULL.test(headerName));
153
165
  }
154
166
 
155
167
  // Resolve `absPath` via fs.realpathSync, walking up to the nearest existing
@@ -172,9 +184,7 @@ export function realpathNearestExistingAncestor(absPath) {
172
184
  // Pre-validator (throws): walks every parsed entry and enforces
173
185
  // - native engine is enabled
174
186
  // - native binary exists on disk
175
- // - each header is shape-supported (no `..`, no out-of-base absolute)
176
- // - realpath of each non-/dev/null header stays inside the real basePath
177
- // (catches symlink/junction escapes that lexical checks miss)
187
+ // - each header has a usable target path (shape only)
178
188
  // - no duplicate target paths in the patch (case-insensitive on win32)
179
189
  // Returns the list of normalized entry rows the dispatcher uses to format
180
190
  // output, plus the header-rewrite map for absolute-path normalization.
@@ -199,61 +209,25 @@ export async function preValidateNativeBatch(parsed, basePath) {
199
209
  }
200
210
  }
201
211
  await assertPathsReachable(reachabilityPaths);
202
- let realBase;
203
- try {
204
- realBase = realpathSync(pathResolve(basePath));
205
- } catch (err) {
206
- throw new Error(`apply_patch: base_path unreadable (${err?.code || err?.message || String(err)}): ${basePath}`);
207
- }
208
212
  const entries = [];
209
213
  const seenPaths = new Set();
210
214
  const headerRewrites = [];
211
215
  for (const entry of parsed) {
212
216
  const kind = classifyEntry(entry);
213
217
  const headerName = kind === 'create' ? entry.newFileName : entry.oldFileName;
214
- if (!nativeHeaderSupported(entry, basePath)) {
215
- // Two distinct failure modes share nativeHeaderSupported()===false:
216
- // (a) no usable target path could be parsed from the section header
217
- // (oldFileName/newFileName empty or /dev/null) a SHAPE problem,
218
- // (b) a parsed path that escapes base_path (`..` segment / out-of-base
219
- // absolute) a SECURITY refusal.
220
- // Collapsing both into the "path escapes base_path or contains `..`"
221
- // message mislabels (a) as (b): the model sees a nonsensical reason for a
222
- // patch that uses no `..`, cannot self-correct, and re-submits the same
223
- // patch until the iteration cap. Emit a shape-specific, actionable hint
224
- // for (a) so the loop breaks on the first failure.
225
- const hasUsableHeader = headerName && !DEV_NULL.test(headerName);
226
- if (!hasUsableHeader) {
227
- throw new Error(
228
- 'apply_patch: a file section header could not be parsed (no target path). '
229
- + 'Each section must start with a valid header: `*** Update File: <path>` / '
230
- + '`*** Add File: <path>` / `*** Delete File: <path>` (V4A), or a '
231
- + '`--- a/<path>` + `+++ b/<path>` pair (unified). Wrap multi-hunk V4A '
232
- + 'edits in a `*** Begin Patch` / `*** End Patch` envelope and pass format:"v4a".',
233
- );
234
- }
235
- const display = normalizeOutputPath(stripDiffPrefix(headerName));
236
- throw new Error(`apply_patch: header ${display} is unsupported (path escapes base_path or contains \`..\`).`);
218
+ if (!nativeHeaderSupported(entry)) {
219
+ throw new Error(
220
+ 'apply_patch: a file section header could not be parsed (no target path). '
221
+ + 'Each section must start with a valid header: `*** Update File: <path>` / '
222
+ + '`*** Add File: <path>` / `*** Delete File: <path>` (V4A), or a '
223
+ + '`--- a/<path>` + `+++ b/<path>` pair (unified). Wrap multi-hunk V4A '
224
+ + 'edits in a `*** Begin Patch` / `*** End Patch` envelope and pass format:"v4a".',
225
+ );
237
226
  }
238
227
  if (kind !== 'delete' && !(entry.hunks?.length > 0)) {
239
228
  const display = headerName ? normalizeOutputPath(stripDiffPrefix(headerName)) : '(unknown)';
240
229
  throw new Error(`apply_patch: entry ${display} has no hunks — patch header malformed (use \`@@ -A,B +C,D @@\` per hunk).`);
241
230
  }
242
- // Realpath each non-/dev/null header; nearest-existing-ancestor handles
243
- // create-mode leaves that do not yet exist. A symlink/junction whose
244
- // target escapes basePath fails here even when the lexical check above
245
- // looked safe.
246
- for (const which of ['oldFileName', 'newFileName']) {
247
- const checkName = entry[which];
248
- if (!checkName || DEV_NULL.test(checkName)) continue;
249
- const checkFull = resolveEntryPath(basePath, checkName);
250
- const checkReal = realpathNearestExistingAncestor(checkFull);
251
- const checkRel = pathRelative(realBase, checkReal);
252
- if (checkRel.split(/[\\/]+/).some((part) => part === '..') || isAbsolute(checkRel)) {
253
- const display = normalizeOutputPath(stripDiffPrefix(checkName));
254
- throw new Error(`apply_patch: ${display} resolves outside base_path via symlink/junction; refusing to apply.`);
255
- }
256
- }
257
231
  const fullPath = resolveEntryPath(basePath, headerName);
258
232
  const pathKey = process.platform === 'win32' ? fullPath.toLowerCase() : fullPath;
259
233
  if (seenPaths.has(pathKey)) {
@@ -272,19 +246,18 @@ export async function preValidateNativeBatch(parsed, basePath) {
272
246
  hunks: entry.hunks?.length || 0,
273
247
  linesChanged: added + removed,
274
248
  });
275
- // Absolute-form headers must be rewritten to repo-relative before the
276
- // native server, which joins headers to basePath, sees them.
249
+ // Absolute-form headers must be rewritten to paths relative to basePath
250
+ // (including `..` segments for out-of-base targets) before the native
251
+ // server, which joins headers to basePath, sees them.
277
252
  for (const which of ['oldFileName', 'newFileName']) {
278
253
  const raw = entry[which];
279
254
  if (!raw || DEV_NULL.test(raw)) continue;
280
255
  const stripped = stripDiffPrefix(raw);
281
256
  const norm = normalizeInputPath(stripped);
282
257
  if (!isAbsolute(norm) && !/^[A-Za-z]:[\\/]/.test(norm)) continue;
283
- const rel = pathRelative(pathResolve(basePath), pathResolve(norm)).replace(/\\/g, '/');
284
- if (!rel || rel.startsWith('..') || isAbsolute(rel)) {
285
- const display = normalizeOutputPath(stripDiffPrefix(raw));
286
- throw new Error(`apply_patch: absolute header ${display} does not resolve inside base_path.`);
287
- }
258
+ if (isResolvedPathOutsideBase(pathResolve(norm), basePath)) continue;
259
+ const rel = headerRelFromBase(basePath, norm);
260
+ if (!rel || rel.startsWith('..')) continue;
288
261
  headerRewrites.push({ from: raw, to: rel });
289
262
  }
290
263
  }
@@ -2,11 +2,10 @@
2
2
  // conversion. Moved verbatim from patch.mjs; anchor/context matching, EOF
3
3
  // handling, rename atomicity, and conversion output are all unchanged.
4
4
 
5
- import { readFileSync, realpathSync, statSync, mkdirSync } from 'node:fs';
5
+ import { readFileSync, lstatSync, mkdirSync } from 'node:fs';
6
6
  import { unlink } from 'node:fs/promises';
7
- import { resolve as pathResolve, relative as pathRelative, isAbsolute, dirname as pathDirname } from 'node:path';
7
+ import { dirname as pathDirname } from 'node:path';
8
8
  import {
9
- normalizeInputPath,
10
9
  normalizeOutputPath,
11
10
  invalidateBuiltinResultCache,
12
11
  clearReadSnapshotForPath,
@@ -18,7 +17,8 @@ import {
18
17
  import { atomicWrite } from '../builtin/atomic-write.mjs';
19
18
  import { assertPathReachable, assertPathsReachable } from '../builtin/fs-reachability.mjs';
20
19
  import { markCodeGraphDirtyPaths } from '../code-graph-state.mjs';
21
- import { resolveV4AEntryPath, realpathNearestExistingAncestor } from './paths.mjs';
20
+ import { isSpecialFileStat } from '../builtin/device-paths.mjs';
21
+ import { resolveV4AEntryPath } from './paths.mjs';
22
22
  import {
23
23
  isV4AEndOfFileMarker,
24
24
  parseUnifiedBareV4APatch,
@@ -219,7 +219,7 @@ function resolveV4AHunkPosition(sourceLines, hunk, nextSearchLine, options = {})
219
219
  };
220
220
  }
221
221
 
222
- function applyV4AHunksToLines(sourceLines, hunks, options = {}) {
222
+ export function applyV4AHunksToLines(sourceLines, hunks, options = {}) {
223
223
  const lines = cloneTextLinesForPatch(sourceLines);
224
224
  const orderedHunks = orderV4AHunksByFilePosition(lines, hunks, options.fuzzy !== false);
225
225
  let nextSearchLine = 0;
@@ -304,28 +304,21 @@ function v4aRenamePathKey(absPath) {
304
304
  return process.platform === 'win32' ? String(absPath || '').toLowerCase() : String(absPath || '');
305
305
  }
306
306
 
307
- function v4aRenamePathInsideRealBase(absPath, realBase) {
308
- const checkReal = realpathNearestExistingAncestor(absPath);
309
- const checkRel = pathRelative(realBase, checkReal);
310
- if (!checkRel || checkRel.startsWith('..') || isAbsolute(checkRel)) return false;
311
- return !checkRel.split(/[\\/]+/).some((part) => part === '..');
307
+ function v4aSpecialFileStatMessage(displayPath) {
308
+ return `apply_patch: cannot patch special file (FIFO / character / block device / socket): ${normalizeOutputPath(displayPath)}`;
312
309
  }
313
310
 
314
- function validateV4ARenameSection(section, basePath, seenDestKeys, realBase) {
315
- const escapeErr = v4aSectionPathEscapeError(section, basePath);
316
- if (escapeErr) return escapeErr;
317
- const escapeDest = v4aSectionPathEscapeError({ path: section.movePath }, basePath);
318
- if (escapeDest) return escapeDest;
311
+ function lstatV4APatchTarget(fullPath, displayPath) {
312
+ const st = lstatSync(fullPath);
313
+ if (isSpecialFileStat(st)) {
314
+ throw new Error(v4aSpecialFileStatMessage(displayPath));
315
+ }
316
+ return st;
317
+ }
318
+
319
+ function validateV4ARenameSection(section, basePath, seenDestKeys) {
319
320
  const srcFull = resolveV4AEntryPath(basePath, section.path);
320
321
  const destFull = resolveV4AEntryPath(basePath, section.movePath);
321
- if (realBase) {
322
- if (!v4aRenamePathInsideRealBase(srcFull, realBase)) {
323
- return `apply_patch: ${normalizeOutputPath(section.path)} resolves outside base_path via symlink/junction; refusing V4A rename.`;
324
- }
325
- if (!v4aRenamePathInsideRealBase(destFull, realBase)) {
326
- return `apply_patch: ${normalizeOutputPath(section.movePath)} resolves outside base_path via symlink/junction; refusing V4A rename.`;
327
- }
328
- }
329
322
  if (v4aRenamePathKey(srcFull) === v4aRenamePathKey(destFull)) {
330
323
  return `apply_patch: V4A rename source and destination are the same path (${normalizeOutputPath(section.path)})`;
331
324
  }
@@ -335,7 +328,10 @@ function validateV4ARenameSection(section, basePath, seenDestKeys, realBase) {
335
328
  }
336
329
  seenDestKeys.add(destKey);
337
330
  try {
338
- const st = statSync(srcFull);
331
+ const st = lstatSync(srcFull);
332
+ if (isSpecialFileStat(st)) {
333
+ return v4aSpecialFileStatMessage(section.path);
334
+ }
339
335
  if (!st.isFile()) {
340
336
  return `apply_patch: V4A rename source is not a regular file: ${normalizeOutputPath(section.path)}`;
341
337
  }
@@ -343,7 +339,10 @@ function validateV4ARenameSection(section, basePath, seenDestKeys, realBase) {
343
339
  return `apply_patch: V4A rename source missing or unreadable: ${normalizeOutputPath(section.path)} (${err?.code || err?.message || String(err)})`;
344
340
  }
345
341
  try {
346
- const destSt = statSync(destFull);
342
+ const destSt = lstatSync(destFull);
343
+ if (isSpecialFileStat(destSt)) {
344
+ return v4aSpecialFileStatMessage(section.movePath);
345
+ }
347
346
  if (destSt.isDirectory()) {
348
347
  return `apply_patch: V4A rename destination is a directory: ${normalizeOutputPath(section.movePath)}`;
349
348
  }
@@ -451,15 +450,9 @@ export async function planV4ARenameSections(sections, basePath) {
451
450
  resolveV4AEntryPath(basePath, section.movePath),
452
451
  ]);
453
452
  await assertPathsReachable(renameReachPaths);
454
- let realBase;
455
- try {
456
- realBase = realpathSync(pathResolve(basePath));
457
- } catch (err) {
458
- throw new Error(`apply_patch: base_path unreadable (${err?.code || err?.message || String(err)}): ${basePath}`);
459
- }
460
453
  const seenDestKeys = new Set();
461
454
  for (const section of renameSections) {
462
- const errText = validateV4ARenameSection(section, basePath, seenDestKeys, realBase);
455
+ const errText = validateV4ARenameSection(section, basePath, seenDestKeys);
463
456
  if (errText) throw new Error(errText);
464
457
  }
465
458
  return {
@@ -485,29 +478,8 @@ export function convertUnifiedCountedToUnifiedPatchViaV4A(patchStr, basePath, op
485
478
  return convertV4ASectionsToUnifiedPatch(parseUnifiedCountedAsV4APatch(patchStr), basePath, options);
486
479
  }
487
480
 
488
- // Lexical path-escape guard for V4A section paths.
489
- function v4aSectionPathEscapeError(section, basePath) {
490
- const raw = section?.path;
491
- if (!raw) return null;
492
- const norm = normalizeInputPath(raw);
493
- const segs = norm.split(/[\\/]+/);
494
- if (segs.some((part) => part === '..')) {
495
- return `apply_patch: header ${normalizeOutputPath(raw)} is unsupported (path escapes base_path or contains \`..\`).`;
496
- }
497
- if (isAbsolute(norm) || /^[A-Za-z]:[\\/]/.test(norm)) {
498
- if (!basePath) return `apply_patch: header ${normalizeOutputPath(raw)} is unsupported (path escapes base_path or contains \`..\`).`;
499
- const absHeader = pathResolve(norm);
500
- const absBase = pathResolve(basePath);
501
- const rel = pathRelative(absBase, absHeader);
502
- if (!rel || rel.startsWith('..') || isAbsolute(rel) || rel.split(/[\\/]+/).some((part) => part === '..')) {
503
- return `apply_patch: header ${normalizeOutputPath(raw)} is unsupported (path escapes base_path or contains \`..\`).`;
504
- }
505
- }
506
- return null;
507
- }
508
-
509
481
  function readRawBufForV4AConversion(fullPath) {
510
- const st = statSync(fullPath);
482
+ const st = lstatV4APatchTarget(fullPath, fullPath);
511
483
  const cached = rawContentCacheGet(fullPath, st);
512
484
  if (cached) return cached;
513
485
  const rawBuf = readFileSync(fullPath);
@@ -543,8 +515,6 @@ export async function convertV4ASectionsToUnifiedPatch(sections, basePath, optio
543
515
  const out = [];
544
516
  const v4aLinesCache = new Map();
545
517
  for (const section of sections) {
546
- const escapeErr = v4aSectionPathEscapeError(section, basePath);
547
- if (escapeErr) throw new Error(escapeErr);
548
518
  const displayPath = section.path.replace(/\\/g, '/');
549
519
  if (section.kind === 'add') {
550
520
  out.push('--- /dev/null');
@@ -114,7 +114,10 @@ function _mergeDeliveryRows(prior, entry) {
114
114
  return prior ? { ...prior, ...entry } : entry;
115
115
  }
116
116
  function _isBlockingDeliveryStatus(status) {
117
- return status === "received" || status === "processing" || status === "done";
117
+ // "pending" is a non-terminal claim too: between the pending row write and
118
+ // the terminal done/failed row (eventPipeline enqueue, delegate dispatch) a
119
+ // concurrent retry of the same delivery id must dedup, not double-dispatch.
120
+ return status === "received" || status === "pending" || status === "processing" || status === "done";
118
121
  }
119
122
  function _deliveryIndexFor(name) {
120
123
  let map = _deliveryIndexByEndpoint.get(name);
@@ -13,6 +13,7 @@ export function createSettingsApi({
13
13
  getSession,
14
14
  getRemoteEnabled,
15
15
  // config-lifecycle
16
+ adoptConfig,
16
17
  saveConfigAndAdopt,
17
18
  scheduleBackendSave,
18
19
  // normalizers / helpers
@@ -129,6 +130,18 @@ export function createSettingsApi({
129
130
  // setProfile patches title and/or language and persists. Unknown language
130
131
  // ids normalize back to 'system'. Prompt-side injection is wired separately
131
132
  // (composeSystemPrompt) — this only owns the stored value.
133
+ getDisabledSkills() {
134
+ const config = getConfig();
135
+ return cfgMod.normalizeSkillsConfig(config.skills);
136
+ },
137
+ setDisabledSkills(disabled) {
138
+ const names = disabled instanceof Set
139
+ ? [...disabled]
140
+ : (Array.isArray(disabled) ? disabled : []);
141
+ const nextSkills = cfgMod.patchSkillsDisabled(names);
142
+ adoptConfig({ ...getConfig(), skills: nextSkills });
143
+ return this.getDisabledSkills();
144
+ },
132
145
  setProfile(input = {}) {
133
146
  const config = getConfig();
134
147
  const current = cfgMod.normalizeProfileConfig(config.profile);
@@ -3,6 +3,8 @@
3
3
  // application/selection logic. Pure module (session objects passed in).
4
4
  import { clean } from './session-text.mjs';
5
5
  import { estimateToolSchemaTokens } from '../runtime/agent/orchestrator/session/context-utils.mjs';
6
+ import { applyInitialDeferredToolManifestToBp1 } from '../runtime/agent/orchestrator/context/collect.mjs';
7
+ import { getMcpServerInstructionsMap } from '../runtime/agent/orchestrator/mcp/client.mjs';
6
8
  import {
7
9
  isResponsesFreeformTool,
8
10
  toResponsesCustomTool,
@@ -64,6 +66,7 @@ export const DEFERRED_DEFAULT_LEAD_TOOLS = Object.freeze([
64
66
  'search',
65
67
  'web_fetch',
66
68
  'cwd',
69
+ 'session_manage',
67
70
  'Skill',
68
71
  'tool_search',
69
72
  ]);
@@ -553,7 +556,21 @@ function setDeferredToolState(session, names) {
553
556
  return selected;
554
557
  }
555
558
 
556
- function isReadonlySelectable(tool) {
559
+ export function deferredPoolToolNames(session) {
560
+ if (!session || session.deferredProviderMode === 'full') return [];
561
+ const catalog = Array.isArray(session.deferredToolCatalog)
562
+ ? session.deferredToolCatalog
563
+ : [];
564
+ const active = new Set((session.tools || []).map((tool) => clean(tool?.name)).filter(Boolean));
565
+ const out = [];
566
+ for (const tool of catalog) {
567
+ const name = clean(tool?.name);
568
+ if (name && !active.has(name)) out.push(name);
569
+ }
570
+ return sortedNamesByMeasuredUsage(out);
571
+ }
572
+
573
+ export function isReadonlySelectable(tool) {
557
574
  const name = clean(tool?.name);
558
575
  if (READONLY_TOOL_NAMES.has(name)) return true;
559
576
  const annotations = tool?.annotations || {};
@@ -602,16 +619,24 @@ export function applyDeferredToolSurface(session, mode, extraTools = [], options
602
619
  } else {
603
620
  setDeferredToolState(session, active);
604
621
  }
622
+ if (!session.deferredToolBp1Applied && session.messages?.some((m) => m?.role === 'system')) {
623
+ if (!session.mcpServerInstructions || typeof session.mcpServerInstructions !== 'object') {
624
+ session.mcpServerInstructions = getMcpServerInstructionsMap();
625
+ }
626
+ applyInitialDeferredToolManifestToBp1(session, deferredPoolToolNames(session));
627
+ }
605
628
  return session;
606
629
  }
607
630
 
608
- export function selectDeferredTools(session, names, mode) {
631
+ export function selectDeferredTools(session, names, mode, options = {}) {
632
+ const promoteToActive = options?.promoteToActive === true;
609
633
  const catalog = Array.isArray(session?.deferredToolCatalog)
610
634
  ? session.deferredToolCatalog
611
635
  : (Array.isArray(session?.tools) ? session.tools : []);
612
636
  const active = new Set((session?.tools || []).map((tool) => clean(tool?.name)).filter(Boolean));
613
637
  const native = session?.deferredProviderMode === 'native' || session?.deferredNativeTools === true;
614
638
  const discovered = new Set(Array.isArray(session?.deferredDiscoveredTools) ? session.deferredDiscoveredTools : []);
639
+ const activateOnSurface = !native || promoteToActive;
615
640
  const byName = new Map();
616
641
  for (const tool of catalog) {
617
642
  const name = clean(tool?.name);
@@ -635,20 +660,23 @@ export function selectDeferredTools(session, names, mode) {
635
660
  blocked.push({ name, reason: 'readonly mode' });
636
661
  continue;
637
662
  }
638
- if (active.has(name) || discovered.has(name)) {
663
+ if (active.has(name) || (!activateOnSurface && discovered.has(name))) {
639
664
  already.push(name);
640
665
  continue;
641
666
  }
642
- if (native) {
643
- discovered.add(name);
644
- } else {
667
+ if (activateOnSurface) {
645
668
  session.tools.push(tool);
646
669
  active.add(name);
670
+ discovered.delete(name);
671
+ } else {
672
+ discovered.add(name);
647
673
  }
648
674
  added.push(name);
649
675
  }
650
676
  if (native) {
651
- session.deferredDiscoveredTools = sortedNamesByMeasuredUsage(discovered);
677
+ session.deferredDiscoveredTools = sortedNamesByMeasuredUsage(
678
+ [...discovered].filter((toolName) => !active.has(toolName)),
679
+ );
652
680
  session.deferredSelectedTools = sortedNamesByMeasuredUsage(active);
653
681
  } else {
654
682
  setDeferredToolState(session, active);
@@ -69,6 +69,34 @@ export const SKILL_TOOL = {
69
69
  },
70
70
  };
71
71
 
72
+ // Owner-directed session reset tool. `clear` mirrors /clear (full wipe);
73
+ // `compact_clear` mirrors the auto-clear path (summarize via the configured
74
+ // compactType, then reset — context carries forward in the summary). The
75
+ // reset is SCHEDULED: it runs when the current turn ends, never mid-turn,
76
+ // because the live transcript is still feeding the loop. Lead-session only;
77
+ // the runtime executor rejects agent-worker callers.
78
+ export const SESSION_MANAGE_TOOL = {
79
+ name: 'session_manage',
80
+ title: 'Session Manage',
81
+ annotations: {
82
+ title: 'Session Manage',
83
+ readOnlyHint: false,
84
+ destructiveHint: true,
85
+ idempotentHint: false,
86
+ openWorldHint: false,
87
+ agentHidden: true,
88
+ },
89
+ description: 'Reset this conversation on explicit user request. action=clear wipes all context (like /clear); action=compact_clear summarizes first and carries context forward (auto-clear style). Applies when the current turn ends.',
90
+ inputSchema: {
91
+ type: 'object',
92
+ properties: {
93
+ action: { type: 'string', enum: ['clear', 'compact_clear'], description: 'clear = full wipe; compact_clear = summarize then reset.' },
94
+ },
95
+ required: ['action'],
96
+ additionalProperties: false,
97
+ },
98
+ };
99
+
72
100
  export const LEAD_DISALLOWED_TOOLS = Object.freeze([]);
73
101
  export const AGENT_HIDDEN_WRAPPER_TOOLS = new Set([]);
74
102
 
@@ -82,6 +82,8 @@ export function renderResult(value) {
82
82
  lines.push(`status: ${value.status}`);
83
83
  if (value.type) lines.push(`type: ${value.type}`);
84
84
  if (value.reused) lines.push('reused: true');
85
+ if (value.respawned) lines.push('respawned: true');
86
+ if (value.note) lines.push(`note: ${value.note}`);
85
87
  if (value.tag || value.sessionId) lines.push(`target: ${value.tag || '-'} ${value.sessionId || ''}`.trim());
86
88
  if (value.agent) lines.push(`agent: ${value.agent}`);
87
89
  if (value.provider && value.model) lines.push(`model: ${value.provider}/${value.model}`);