mixdog 0.9.25 → 0.9.27
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 +2 -1
- package/scripts/arg-guard-test.mjs +68 -0
- package/scripts/compacted-placeholder-scrub-test.mjs +77 -0
- package/scripts/steering-fold-provenance-test.mjs +71 -0
- package/scripts/webhook-smoke.mjs +46 -53
- package/src/cli.mjs +40 -4
- package/src/defaults/skills/setup/SKILL.md +6 -1
- package/src/rules/shared/01-tool.md +11 -4
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +3 -1
- package/src/runtime/agent/orchestrator/agent-trace-io.mjs +17 -0
- package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +2 -2
- package/src/runtime/agent/orchestrator/mcp/client.mjs +28 -10
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +8 -1
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +14 -1
- package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +51 -15
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +5 -1
- package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +164 -9
- package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +75 -0
- package/src/runtime/agent/orchestrator/session/loop/transcript-repair.mjs +5 -0
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +4 -0
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +12 -1
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +14 -1
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/shell-state.mjs +7 -3
- package/src/runtime/channels/lib/config.mjs +13 -11
- package/src/runtime/channels/lib/memory-client.mjs +22 -2
- package/src/runtime/channels/lib/owned-runtime.mjs +1 -1
- package/src/runtime/channels/lib/scheduler.mjs +226 -208
- package/src/runtime/channels/lib/status-snapshot.mjs +16 -0
- package/src/runtime/channels/lib/webhook/deliveries.mjs +7 -318
- package/src/runtime/channels/lib/webhook.mjs +98 -150
- package/src/runtime/channels/lib/worker-main.mjs +1 -1
- package/src/runtime/memory/index.mjs +50 -25
- package/src/runtime/memory/lib/cycle-scheduler.mjs +3 -1
- package/src/runtime/memory/lib/memory-config-flags.mjs +13 -1
- package/src/runtime/memory/lib/pg/adapter.mjs +6 -1
- package/src/runtime/memory/lib/pg/process.mjs +19 -1
- package/src/runtime/memory/lib/pg/supervisor.mjs +125 -14
- package/src/runtime/memory/lib/query-handlers.mjs +102 -0
- package/src/runtime/memory/lib/recall-format.mjs +60 -22
- package/src/runtime/memory/lib/session-ingest-runtime.mjs +20 -6
- package/src/runtime/memory/tool-defs.mjs +3 -3
- package/src/runtime/shared/child-guardian.mjs +2 -2
- package/src/runtime/shared/open-url.mjs +2 -1
- package/src/runtime/shared/schedules-db.mjs +350 -0
- package/src/runtime/shared/service-discovery.mjs +169 -0
- package/src/runtime/shared/spawn-flags.mjs +27 -0
- package/src/runtime/shared/staged-install-worker.mjs +14 -0
- package/src/runtime/shared/staged-update.mjs +530 -0
- package/src/runtime/shared/tool-primitives.mjs +3 -1
- package/src/runtime/shared/tool-surface.mjs +19 -3
- package/src/runtime/shared/update-checker.mjs +54 -10
- package/src/runtime/shared/webhooks-db.mjs +405 -0
- package/src/session-runtime/channel-config-api.mjs +13 -13
- package/src/session-runtime/lifecycle-api.mjs +12 -0
- package/src/session-runtime/runtime-core.mjs +41 -23
- package/src/standalone/agent-tool/tool-def.mjs +1 -1
- package/src/standalone/agent-tool.mjs +42 -11
- package/src/standalone/channel-admin.mjs +173 -121
- package/src/standalone/channel-worker.mjs +2 -2
- package/src/standalone/memory-runtime-proxy.mjs +10 -5
- package/src/tui/App.jsx +32 -10
- package/src/tui/app/channel-pickers.mjs +9 -9
- package/src/tui/app/clipboard.mjs +14 -0
- package/src/tui/app/doctor.mjs +1 -1
- package/src/tui/app/maintenance-pickers.mjs +17 -7
- package/src/tui/app/settings-picker.mjs +2 -2
- package/src/tui/app/transcript-window.mjs +63 -7
- package/src/tui/app/use-mouse-input.mjs +19 -6
- package/src/tui/components/Spinner.jsx +5 -2
- package/src/tui/components/StatusLine.jsx +7 -7
- package/src/tui/components/ToolExecution.jsx +36 -62
- package/src/tui/display-width.mjs +18 -8
- package/src/tui/dist/index.mjs +502 -324
- package/src/tui/engine/session-api-ext.mjs +12 -12
- package/src/tui/hooks/useSharedTick.mjs +103 -0
- package/src/tui/index.jsx +12 -2
- package/src/tui/markdown/format-token.mjs +46 -8
- package/src/tui/markdown/render-ansi.mjs +24 -1
- package/src/tui/markdown/stream-fence.mjs +60 -0
- package/src/tui/markdown/streaming-markdown.mjs +22 -1
- package/src/ui/statusline-segments.mjs +12 -1
- package/vendor/ink/build/display-width.js +10 -8
- package/src/runtime/shared/schedules-store.mjs +0 -82
|
@@ -397,7 +397,13 @@ function toAnthropicMessages(messages) {
|
|
|
397
397
|
// First-party client parity: fold a user text turn that directly follows a
|
|
398
398
|
// tool_result turn into that tool_result's content (empty end_turn
|
|
399
399
|
// livelock prevention; see foldUserTextIntoToolResultTail).
|
|
400
|
-
|
|
400
|
+
// EXCEPTION: steering-origin user messages (human/TUI interjections)
|
|
401
|
+
// keep their own user turn so provenance survives — folding them would
|
|
402
|
+
// disguise user input as tool output. Anthropic accepts a user text
|
|
403
|
+
// message after a tool_result message, so the turn stays request-valid.
|
|
404
|
+
const isSteering = m.role === 'user' && m.meta?.source === 'steering';
|
|
405
|
+
if (m.role === 'user' && !isSteering
|
|
406
|
+
&& foldUserTextIntoToolResultTail(result, normalizeContentForAnthropic(m.content))) {
|
|
401
407
|
continue;
|
|
402
408
|
}
|
|
403
409
|
result.push({ role: m.role, content: normalizeContentForAnthropic(m.content) });
|
|
@@ -405,6 +411,13 @@ function toAnthropicMessages(messages) {
|
|
|
405
411
|
return sanitizeAnthropicContentPairs(result);
|
|
406
412
|
}
|
|
407
413
|
|
|
414
|
+
// Test-only: expose the lowering so the steering-provenance test can assert
|
|
415
|
+
// the API-key provider keeps steering-tagged user turns distinct (mirrors
|
|
416
|
+
// anthropic-oauth._buildRequestBodyForCacheSmoke coverage).
|
|
417
|
+
export function _toAnthropicMessagesForTest(messages) {
|
|
418
|
+
return toAnthropicMessages(messages);
|
|
419
|
+
}
|
|
420
|
+
|
|
408
421
|
// Applies cache_control markers to the FINAL, already-sanitized Anthropic
|
|
409
422
|
// message array — by INVARIANT, never by pre-sanitize index. Because
|
|
410
423
|
// sanitizeAnthropicContentPairs has already run (and must NOT run again
|
|
@@ -242,10 +242,15 @@ function windowFromPercent(label, value, source) {
|
|
|
242
242
|
}
|
|
243
243
|
|
|
244
244
|
// Grok Build billing periods are migrating from monthly to a shared weekly
|
|
245
|
-
// pool (xAI rollout is per-account).
|
|
246
|
-
//
|
|
247
|
-
//
|
|
245
|
+
// pool (xAI rollout is per-account). Unified-billing accounts state the
|
|
246
|
+
// cadence explicitly via currentPeriod.type (USAGE_PERIOD_TYPE_WEEKLY);
|
|
247
|
+
// otherwise derive it from the actual billing period length instead of
|
|
248
|
+
// hardcoding monthly: <=10 days means a weekly cycle, anything longer (or
|
|
249
|
+
// unknown) stays monthly.
|
|
248
250
|
function grokBillingCadence(config) {
|
|
251
|
+
const periodType = cleanString(config?.currentPeriod?.type ?? config?.current_period?.type ?? '').toUpperCase();
|
|
252
|
+
if (periodType.includes('WEEKLY')) return { label: 'W', period: 'weekly' };
|
|
253
|
+
if (periodType.includes('MONTHLY')) return { label: 'M', period: 'monthly' };
|
|
249
254
|
if (config?.weeklyLimit !== undefined || config?.weekly_limit !== undefined) {
|
|
250
255
|
return { label: 'W', period: 'weekly' };
|
|
251
256
|
}
|
|
@@ -259,6 +264,11 @@ function grokBillingCadence(config) {
|
|
|
259
264
|
|
|
260
265
|
function creditWindowFromBilling(config) {
|
|
261
266
|
if (!config || typeof config !== 'object') return null;
|
|
267
|
+
const cadence = grokBillingCadence(config);
|
|
268
|
+
const resetAt = resetAtMs(
|
|
269
|
+
config.currentPeriod?.end ?? config.current_period?.end
|
|
270
|
+
?? config.billingPeriodEnd ?? config.billing_period_end,
|
|
271
|
+
);
|
|
262
272
|
const limit = num(
|
|
263
273
|
config.weeklyLimit?.val ?? config.weeklyLimit
|
|
264
274
|
?? config.monthlyLimit?.val ?? config.monthlyLimit
|
|
@@ -266,10 +276,27 @@ function creditWindowFromBilling(config) {
|
|
|
266
276
|
null,
|
|
267
277
|
);
|
|
268
278
|
const used = num(config.used?.val ?? config.includedUsed?.val ?? config.used, null);
|
|
269
|
-
if (limit === null || used === null || !(limit > 0))
|
|
270
|
-
|
|
279
|
+
if (limit === null || used === null || !(limit > 0)) {
|
|
280
|
+
// Unified-billing accounts (shared weekly pool) report utilization as a
|
|
281
|
+
// percentage instead of credit totals; absent means 0% used.
|
|
282
|
+
const unified = config.isUnifiedBillingUser === true
|
|
283
|
+
|| config.is_unified_billing_user === true
|
|
284
|
+
|| config.currentPeriod || config.current_period;
|
|
285
|
+
if (!unified) return null;
|
|
286
|
+
const usedPct = num(
|
|
287
|
+
config.creditUsagePercent?.val ?? config.creditUsagePercent
|
|
288
|
+
?? config.credit_usage_percent,
|
|
289
|
+
0,
|
|
290
|
+
);
|
|
291
|
+
return {
|
|
292
|
+
label: cadence.label,
|
|
293
|
+
source: 'grok-build-billing',
|
|
294
|
+
usedPct: round(Math.min(100, Math.max(0, usedPct)), 2),
|
|
295
|
+
...(resetAt ? { resetAt } : {}),
|
|
296
|
+
};
|
|
297
|
+
}
|
|
271
298
|
return {
|
|
272
|
-
label:
|
|
299
|
+
label: cadence.label,
|
|
273
300
|
source: 'grok-build-billing',
|
|
274
301
|
usedPct: round(Math.min(100, used * 100 / limit), 2),
|
|
275
302
|
usedCredits: round(used, 2),
|
|
@@ -495,29 +522,38 @@ async function fetchGrokUsage(providerObj, routeInfo) {
|
|
|
495
522
|
Authorization: `Bearer ${token}`,
|
|
496
523
|
'X-XAI-Token-Auth': 'xai-grok-cli',
|
|
497
524
|
'x-userid': userId,
|
|
525
|
+
'x-grok-client-version': '0.2.87',
|
|
498
526
|
Accept: 'application/json',
|
|
499
|
-
'User-Agent': 'xai-grok-build/0.2.
|
|
527
|
+
'User-Agent': 'xai-grok-build/0.2.87',
|
|
500
528
|
};
|
|
501
529
|
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
530
|
+
// format=credits is what the official grok CLI requests; unified-billing
|
|
531
|
+
// (shared weekly pool) accounts only report their real cadence and reset
|
|
532
|
+
// there. The legacy /v1/billing shape keeps serving a stale monthly cycle
|
|
533
|
+
// for migrated accounts, so it is only a fallback.
|
|
534
|
+
for (const url of [
|
|
535
|
+
'https://cli-chat-proxy.grok.com/v1/billing?format=credits',
|
|
536
|
+
'https://cli-chat-proxy.grok.com/v1/billing',
|
|
537
|
+
]) {
|
|
538
|
+
try {
|
|
539
|
+
const res = await fetch(url, fetchOptions(cliHeaders));
|
|
540
|
+
if (!res.ok) continue;
|
|
505
541
|
const data = await res.json();
|
|
506
542
|
const config = data?.config && typeof data.config === 'object' ? data.config : data;
|
|
507
|
-
const
|
|
508
|
-
if (
|
|
543
|
+
const window = creditWindowFromBilling(config);
|
|
544
|
+
if (window) {
|
|
509
545
|
return {
|
|
510
546
|
provider: routeInfo?.provider || 'grok-oauth',
|
|
511
547
|
model: routeInfo?.model || null,
|
|
512
548
|
source: 'grok-build-billing',
|
|
513
|
-
quotaWindows: [
|
|
549
|
+
quotaWindows: [window],
|
|
514
550
|
balance: balanceFromGrokBilling(config),
|
|
515
551
|
rawKeys: Object.keys(data || {}).sort(),
|
|
516
552
|
};
|
|
517
553
|
}
|
|
554
|
+
} catch {
|
|
555
|
+
// Fall through to the next candidate / generic probes below.
|
|
518
556
|
}
|
|
519
|
-
} catch {
|
|
520
|
-
// Fall through to generic probes below.
|
|
521
557
|
}
|
|
522
558
|
|
|
523
559
|
// xAI documents per-request cost tracking and console rate-limit pages, but
|
|
@@ -202,7 +202,11 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
202
202
|
if (typeof options.beforeAppend === 'function') {
|
|
203
203
|
try { options.beforeAppend(); } catch { /* best-effort hook */ }
|
|
204
204
|
}
|
|
205
|
-
|
|
205
|
+
// Tag steering-origin user messages so provider lowering keeps them a
|
|
206
|
+
// distinct user turn (see anthropic-oauth foldUserTextIntoToolResultTail):
|
|
207
|
+
// human/TUI steering must stay attributed as user input, never folded
|
|
208
|
+
// into a preceding tool_result where it reads as tool output.
|
|
209
|
+
messages.push({ role: 'user', content: merged.content, meta: { source: 'steering' } });
|
|
206
210
|
try { opts.onSteerMessage?.(merged.text || steeringContentText(merged.content)); } catch {}
|
|
207
211
|
if (sessionId) {
|
|
208
212
|
try { process.stderr.write(`[steer] sess=${sessionId} injected ${stage} user message (merged=${merged.count} len=${String(merged.text || '').length})\n`); } catch {}
|
|
@@ -40,6 +40,38 @@ function _pathsOf(arg) {
|
|
|
40
40
|
function _normPath(p) { return String(p).replace(/\\/g, '/').toLowerCase(); }
|
|
41
41
|
function _baseName(p) { const s = _normPath(p); const i = s.lastIndexOf('/'); return i >= 0 ? s.slice(i + 1) : s; }
|
|
42
42
|
function _dirName(p) { const s = _normPath(p); const i = s.lastIndexOf('/'); return i >= 0 ? s.slice(0, i) : ''; }
|
|
43
|
+
// A path targets a specific file (not a directory scope) when its basename
|
|
44
|
+
// carries a file extension — the signal that a grep was already file-scoped.
|
|
45
|
+
function _isFileScopedPath(p) { return /\.[A-Za-z0-9]{1,12}$/.test(_baseName(p)); }
|
|
46
|
+
// Grep context flag: -C/-A/-B count as context ONLY when > 0. An explicit
|
|
47
|
+
// -C:0 / -A:0 / -B:0 is NO context (bare match lines).
|
|
48
|
+
function _hasGrepContext(a) { return Number(a?.['-A']) > 0 || Number(a?.['-B']) > 0 || Number(a?.['-C']) > 0; }
|
|
49
|
+
// Canonical path segments: normalize slashes/case, drop '.' and resolve '..'
|
|
50
|
+
// so ./ and ../ variants collapse. Used by the abs/rel-tolerant path match.
|
|
51
|
+
function _canonSegs(p) {
|
|
52
|
+
const out = [];
|
|
53
|
+
for (const seg of _normPath(p).split('/')) {
|
|
54
|
+
if (seg === '' || seg === '.') continue;
|
|
55
|
+
if (seg === '..') { if (out.length && out[out.length - 1] !== '..') out.pop(); else out.push('..'); continue; }
|
|
56
|
+
out.push(seg);
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
function _canonPath(p) { return _canonSegs(p).join('/'); }
|
|
61
|
+
// abs/rel-tolerant same-file test: equal when one canonical path is a
|
|
62
|
+
// segment-aligned suffix of the other (so C:/proj/src/x.mjs, src/x.mjs and
|
|
63
|
+
// ./src/x.mjs all match). Basename equality is implied by the suffix compare.
|
|
64
|
+
function _pathsMatch(a, b) {
|
|
65
|
+
if (!a || !b) return false;
|
|
66
|
+
const A = _canonSegs(a); const B = _canonSegs(b);
|
|
67
|
+
if (!A.length || !B.length) return false;
|
|
68
|
+
const long = A.length >= B.length ? A : B;
|
|
69
|
+
const short = A.length >= B.length ? B : A;
|
|
70
|
+
for (let i = 0; i < short.length; i += 1) {
|
|
71
|
+
if (long[long.length - short.length + i] !== short[i]) return false;
|
|
72
|
+
}
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
43
75
|
// Exact (normalized) same-file test — used where "related" is too loose.
|
|
44
76
|
function _samePath(a, b) { return !!a && !!b && _normPath(a) === _normPath(b); }
|
|
45
77
|
// Loose sibling/variant relation — cluster detector pairs this WITH token overlap.
|
|
@@ -148,6 +180,15 @@ export function createSteeringLadder(ctx) {
|
|
|
148
180
|
// same-path regions in ONE call are the recommended batched form and are
|
|
149
181
|
// never counted. Fires once per path per session.
|
|
150
182
|
const _readWindowsByPath = new Map();
|
|
183
|
+
// Detector A state: prior turn's file-scoped grep paths that ran WITHOUT
|
|
184
|
+
// any -C/-B/-A context (a bare match-line grep on a specific file). Seeds
|
|
185
|
+
// the grep-no-context-then-read nudge for the next turn.
|
|
186
|
+
let _lastFileScopedGrepNoCtxPaths = null;
|
|
187
|
+
// Detector B state: per-path consecutive-read streak (canonical path ->
|
|
188
|
+
// count). Tracked as a Map so multi-file turns keep independent streaks
|
|
189
|
+
// ([A,B]->B->B fires on B). Files not read this turn are pruned; a path is
|
|
190
|
+
// dropped after it fires.
|
|
191
|
+
const _readStreaks = new Map();
|
|
151
192
|
|
|
152
193
|
// Level-2 steering emitter shared by both ladder paths (single-call
|
|
153
194
|
// level-1 streak and the independent all-read-only streak). Sets the latch
|
|
@@ -178,20 +219,37 @@ export function createSteeringLadder(ctx) {
|
|
|
178
219
|
// Steering hint gate: at most ONE hint per turn (priority:
|
|
179
220
|
// level-2 > grep/shell detectors > level-1).
|
|
180
221
|
let _hintFiredThisTurn = hintAlreadyFired;
|
|
222
|
+
// Specific-detector predicates (A: grep-no-context then re-read; B:
|
|
223
|
+
// 3rd consecutive same-file read). Evaluated up front so the GENERIC
|
|
224
|
+
// level-1 batching nudge can defer to them (more specific wins),
|
|
225
|
+
// while the level-2 runaway escalation below still outranks them.
|
|
226
|
+
const _readCallsThisTurn = calls.filter((c) => c?.name === 'read');
|
|
227
|
+
const _aWouldFire = !!_lastFileScopedGrepNoCtxPaths
|
|
228
|
+
&& _readCallsThisTurn.some((c) => _pathsOf(c?.arguments?.path)
|
|
229
|
+
.some((p) => [..._lastFileScopedGrepNoCtxPaths].some((g) => _pathsMatch(p, g))));
|
|
230
|
+
let _bWouldFire = false;
|
|
231
|
+
for (const c of _readCallsThisTurn) {
|
|
232
|
+
for (const w of _readWindows(c)) {
|
|
233
|
+
for (const [k, cnt] of _readStreaks) { if (cnt >= 2 && _pathsMatch(k, w.path)) { _bWouldFire = true; break; } }
|
|
234
|
+
if (_bWouldFire) break;
|
|
235
|
+
}
|
|
236
|
+
if (_bWouldFire) break;
|
|
237
|
+
}
|
|
181
238
|
// Missed-parallelism steering: 2+ consecutive turns of a single
|
|
182
239
|
// read-only tool call suggest the model isn't batching independent
|
|
183
240
|
// lookups. Nudge once, then reset (fires again after 2 more).
|
|
184
241
|
if (calls.length === 1 && isEagerDispatchable(calls[0].name, tools)) {
|
|
185
242
|
_serialReadOnlyStreak += 1;
|
|
186
|
-
|
|
243
|
+
// Escalation ladder (Step 1). Cumulative level-1 fires are tracked
|
|
244
|
+
// and NEVER reset. Once level-1 has fired >=3 times with ZERO edits,
|
|
245
|
+
// escalate to level-2 steering (latched once per 5 turns). The
|
|
246
|
+
// level-2 escalation always wins; the plain batching nudge instead
|
|
247
|
+
// DEFERS to the specific A/B detectors when either would fire.
|
|
248
|
+
const _canEscalate = (_level1FireCount + 1) >= 3 && editCount === 0 && (iterations - _level2LatchAtIteration) >= 5;
|
|
249
|
+
if (_serialReadOnlyStreak >= 2 && !_hintFiredThisTurn && (_canEscalate || (!_aWouldFire && !_bWouldFire))) {
|
|
187
250
|
_serialReadOnlyStreak = 0;
|
|
188
|
-
// Escalation ladder (Step 1). Cumulative level-1 fires are
|
|
189
|
-
// tracked and NEVER reset. Once level-1 has fired >=3 times with
|
|
190
|
-
// ZERO edits, escalate to level-2 steering (blocked-report is a
|
|
191
|
-
// valid completion) instead of the batching nudge — latched to at
|
|
192
|
-
// most once per 5 turns.
|
|
193
251
|
_level1FireCount += 1;
|
|
194
|
-
if (
|
|
252
|
+
if (_canEscalate) {
|
|
195
253
|
_emitLevel2Steer();
|
|
196
254
|
} else {
|
|
197
255
|
pushSystemReminder('Last 2 turns each ran a single read-only tool. Batch independent lookups (read/grep/glob/code_graph) into ONE turn, or start editing if you have enough context.');
|
|
@@ -303,8 +361,10 @@ export function createSteeringLadder(ctx) {
|
|
|
303
361
|
const _next = new Set();
|
|
304
362
|
for (const c of _grepCalls) {
|
|
305
363
|
const a = c?.arguments || {};
|
|
306
|
-
|
|
307
|
-
|
|
364
|
+
// Bare output_mode:'content' WITHOUT real context is routed to
|
|
365
|
+
// Detector A (which gives the add-`-C` advice); this detector
|
|
366
|
+
// only owns greps that actually returned context.
|
|
367
|
+
const _isContent = _hasGrepContext(a) || a.output_mode === 'content_with_context';
|
|
308
368
|
const _pathsOnly = a.output_mode === 'files_with_matches' || a.output_mode === 'count';
|
|
309
369
|
const _capped = a.head_limit != null;
|
|
310
370
|
if (_isContent && !_pathsOnly && !_capped) {
|
|
@@ -313,6 +373,55 @@ export function createSteeringLadder(ctx) {
|
|
|
313
373
|
}
|
|
314
374
|
_lastGrepContentPaths = _next.size ? _next : null;
|
|
315
375
|
}
|
|
376
|
+
// Detector A (fire): a file-scoped grep WITHOUT context last turn,
|
|
377
|
+
// then a read of that same file this turn. Fires ahead of the read-
|
|
378
|
+
// fragmentation / B detectors below; the generic level-1 nudge
|
|
379
|
+
// already deferred to it via _aWouldFire, while the level-2
|
|
380
|
+
// escalation above still outranks it. On pre-emption by an
|
|
381
|
+
// earlier hint, the pending set is kept (not cleared) so A can fire
|
|
382
|
+
// next turn.
|
|
383
|
+
let _aPending = false;
|
|
384
|
+
if (_lastFileScopedGrepNoCtxPaths) {
|
|
385
|
+
const _rp = [];
|
|
386
|
+
for (const c of _readCallsThisTurn) { for (const p of _pathsOf(c?.arguments?.path)) _rp.push(p); }
|
|
387
|
+
const _hit = _rp.find((p) => [..._lastFileScopedGrepNoCtxPaths].some((g) => _pathsMatch(p, g)));
|
|
388
|
+
if (_hit) {
|
|
389
|
+
if (!_hintFiredThisTurn) {
|
|
390
|
+
pushSystemReminder(`You grepped ${_baseName(_hit)} last turn then re-opened it — request context with -C on file-scoped grep so the match window is already on screen.`);
|
|
391
|
+
try {
|
|
392
|
+
appendAgentTrace({
|
|
393
|
+
sessionId,
|
|
394
|
+
iteration: iterations,
|
|
395
|
+
kind: 'steer',
|
|
396
|
+
payload: { tag: 'grep_nocontext_reread', file: _baseName(_hit) },
|
|
397
|
+
agent: sessionAgent || null,
|
|
398
|
+
});
|
|
399
|
+
} catch { /* best-effort */ }
|
|
400
|
+
_hintFiredThisTurn = true;
|
|
401
|
+
_lastFileScopedGrepNoCtxPaths = null;
|
|
402
|
+
} else {
|
|
403
|
+
_aPending = true; // pre-empted this turn — keep pending for next turn
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
// Detector A (seed): record this turn's file-scoped greps that ran
|
|
408
|
+
// WITHOUT context so a same-file read next turn triggers the fire
|
|
409
|
+
// block above. When A was pre-empted this turn (_aPending) the
|
|
410
|
+
// existing pending set is kept rather than cleared.
|
|
411
|
+
if (!_aPending) {
|
|
412
|
+
const _next = new Set();
|
|
413
|
+
for (const c of _grepCalls) {
|
|
414
|
+
const a = c?.arguments || {};
|
|
415
|
+
// Exact complement of the content detector's seed: skip greps
|
|
416
|
+
// that carried real context (positive -A/-B/-C) OR ran in
|
|
417
|
+
// content_with_context mode — those belong to that detector.
|
|
418
|
+
if (_hasGrepContext(a) || a.output_mode === 'content_with_context') continue;
|
|
419
|
+
for (const p of _pathsOf(a.path)) {
|
|
420
|
+
if (_isFileScopedPath(p)) _next.add(p);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
_lastFileScopedGrepNoCtxPaths = _next.size ? _next : null;
|
|
424
|
+
}
|
|
316
425
|
// Detector: read fragmentation — 2nd+ single-window read into the
|
|
317
426
|
// SAME file with offsets inside an 800-line span means the model is
|
|
318
427
|
// paging small windows across turns instead of reading one wider
|
|
@@ -345,6 +454,50 @@ export function createSteeringLadder(ctx) {
|
|
|
345
454
|
}
|
|
346
455
|
}
|
|
347
456
|
}
|
|
457
|
+
// Detector B: 3rd consecutive turn reading the SAME file (any
|
|
458
|
+
// window). Per-path streaks in a Map so multi-file turns keep
|
|
459
|
+
// independent counts ([A,B]->B->B fires on B). rel/abs spellings of
|
|
460
|
+
// one file collapse onto a single streak key; files not read this
|
|
461
|
+
// turn are pruned; a path is dropped after it fires.
|
|
462
|
+
{
|
|
463
|
+
// Resolve each read path to one streak key, merging rel/abs
|
|
464
|
+
// spellings against existing keys and keys already seen this turn.
|
|
465
|
+
const _touched = new Set();
|
|
466
|
+
for (const c of calls.filter((x) => x?.name === 'read')) {
|
|
467
|
+
for (const w of _readWindows(c)) {
|
|
468
|
+
let _key = null;
|
|
469
|
+
for (const k of _readStreaks.keys()) { if (_pathsMatch(k, w.path)) { _key = k; break; } }
|
|
470
|
+
if (!_key) { for (const k of _touched) { if (_pathsMatch(k, w.path)) { _key = k; break; } } }
|
|
471
|
+
_touched.add(_key || _canonPath(w.path));
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
// Prune streaks for files not read this turn (breaks consecutiveness).
|
|
475
|
+
for (const k of [..._readStreaks.keys()]) { if (!_touched.has(k)) _readStreaks.delete(k); }
|
|
476
|
+
// Increment each file read this turn (once per turn).
|
|
477
|
+
for (const k of _touched) {
|
|
478
|
+
_readStreaks.set(k, (_readStreaks.get(k) || 0) + 1);
|
|
479
|
+
if (_readStreaks.size > 32) _readStreaks.delete(_readStreaks.keys().next().value);
|
|
480
|
+
}
|
|
481
|
+
if (!_hintFiredThisTurn) {
|
|
482
|
+
for (const [k, cnt] of _readStreaks) {
|
|
483
|
+
if (cnt >= 3) {
|
|
484
|
+
pushSystemReminder(`3rd window into ${_baseName(k)} — take the remaining span in ONE wide read (offset+limit) instead of paging.`);
|
|
485
|
+
try {
|
|
486
|
+
appendAgentTrace({
|
|
487
|
+
sessionId,
|
|
488
|
+
iteration: iterations,
|
|
489
|
+
kind: 'steer',
|
|
490
|
+
payload: { tag: 'consecutive_same_file_read', file: _baseName(k) },
|
|
491
|
+
agent: sessionAgent || null,
|
|
492
|
+
});
|
|
493
|
+
} catch { /* best-effort */ }
|
|
494
|
+
_readStreaks.delete(k);
|
|
495
|
+
_hintFiredThisTurn = true;
|
|
496
|
+
break;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
348
501
|
// Detector: read-only shell (git status/log/diff --stat, ls/dir/cat/
|
|
349
502
|
// type/Get-ChildItem) inspects state the dedicated tools cover.
|
|
350
503
|
// Nudge toward them; never blocks execution.
|
|
@@ -366,6 +519,8 @@ export function createSteeringLadder(ctx) {
|
|
|
366
519
|
_lastGrepContentPaths = null;
|
|
367
520
|
_identGrepStreak = 0;
|
|
368
521
|
_identGrepToken = null;
|
|
522
|
+
_lastFileScopedGrepNoCtxPaths = null;
|
|
523
|
+
_readStreaks.clear();
|
|
369
524
|
},
|
|
370
525
|
};
|
|
371
526
|
}
|
|
@@ -106,3 +106,78 @@ function _restoreCompactedBodies(tcVal, origVal, key) {
|
|
|
106
106
|
}
|
|
107
107
|
return tcVal;
|
|
108
108
|
}
|
|
109
|
+
|
|
110
|
+
// Marker prefix emitted by compactStoredToolArgString for every compacted
|
|
111
|
+
// body/long arg (`[mixdog compacted <key>: <N> chars, sha256:…]`). Both the
|
|
112
|
+
// body-only form (marker alone) and the long form (marker + head/tail preview)
|
|
113
|
+
// begin with this exact span.
|
|
114
|
+
const COMPACTED_MARKER_PREFIX = '[mixdog compacted ';
|
|
115
|
+
|
|
116
|
+
function _isCompactedPlaceholderString(v) {
|
|
117
|
+
return typeof v === 'string' && v.startsWith(COMPACTED_MARKER_PREFIX);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Recursively drop every key whose stored value is a compacted-placeholder
|
|
121
|
+
// string, at any depth (batch shapes like edits[].old_string carry nested
|
|
122
|
+
// compacted bodies too). Non-placeholder values are kept verbatim.
|
|
123
|
+
function _dropCompactedPlaceholders(val) {
|
|
124
|
+
if (Array.isArray(val)) return val.map((item) => _dropCompactedPlaceholders(item));
|
|
125
|
+
if (val && typeof val === 'object') {
|
|
126
|
+
const out = {};
|
|
127
|
+
for (const [k, v] of Object.entries(val)) {
|
|
128
|
+
if (_isCompactedPlaceholderString(v)) continue; // drop the key entirely
|
|
129
|
+
out[k] = _dropCompactedPlaceholders(v);
|
|
130
|
+
}
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
133
|
+
return val;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// A SUCCESSFUL tool call's compacted body/long arg (patch / old_string /
|
|
137
|
+
// new_string / content / rewrite / command / script) is never needed again —
|
|
138
|
+
// the edit already applied. compactToolCallsForHistory (run at push time,
|
|
139
|
+
// before the outcome is known) leaves a `[mixdog compacted …]` placeholder in
|
|
140
|
+
// its place. For a success that placeholder persists in the stored assistant
|
|
141
|
+
// tool_use and is transmitted back as a prior apply_patch INPUT, which the
|
|
142
|
+
// model copies verbatim as new patch args — caught by the patch guard, but only
|
|
143
|
+
// after a wasted turn. Drop those placeholder keys so no resubmittable
|
|
144
|
+
// placeholder body survives in history. Failed calls take the opposite path
|
|
145
|
+
// (restoreToolCallBodyForId expands to the full original); success and failure
|
|
146
|
+
// are mutually exclusive per call id, so this never races the restore path.
|
|
147
|
+
// Cache-safe: the caller runs this before the assistant message is transmitted.
|
|
148
|
+
export function dropCompactedBodyArgsForId(assistantMsg, callId) {
|
|
149
|
+
if (!assistantMsg || !Array.isArray(assistantMsg.toolCalls) || !callId) return;
|
|
150
|
+
const tc = assistantMsg.toolCalls.find((t) => t && t.id === callId);
|
|
151
|
+
if (!tc || !tc.arguments || typeof tc.arguments !== 'object') return;
|
|
152
|
+
tc.arguments = _dropCompactedPlaceholders(tc.arguments);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// PRE-SEND INVARIANT. The per-call paths above run during tool-batch
|
|
156
|
+
// processing: a SUCCESS drops its placeholder body (dropCompactedBodyArgsForId),
|
|
157
|
+
// a FAILURE restores the full original (restoreToolCallBodyForId). But those are
|
|
158
|
+
// gated per call id and several loop paths never reach them — dedup /
|
|
159
|
+
// repeat-failure early-continue, the iteration-cap refusal stub, or a call whose
|
|
160
|
+
// id never matched. Any of those can leave a `[mixdog compacted …]` placeholder
|
|
161
|
+
// sitting in a provider-visible assistant toolCall, which the model then copies
|
|
162
|
+
// back verbatim as new apply_patch input. Enforce the invariant once, right
|
|
163
|
+
// before provider.send: sweep EVERY assistant toolCall arguments tree and drop
|
|
164
|
+
// any placeholder-valued key at any depth (batch shapes like edits[].old_string
|
|
165
|
+
// carry nested compacted bodies too).
|
|
166
|
+
//
|
|
167
|
+
// This does NOT re-inline full bodies, so it never raises history token cost. A
|
|
168
|
+
// failed call whose body was restored upstream holds real patch content (it no
|
|
169
|
+
// longer starts with the compacted marker), so the sweep leaves it untouched —
|
|
170
|
+
// the full body stays available exactly where the failed call needs it. The
|
|
171
|
+
// downstream apply_patch guard (isCompactedPlaceholderPatch) remains as a
|
|
172
|
+
// backstop for any placeholder a model synthesizes from prose.
|
|
173
|
+
export function scrubCompactedPlaceholderToolCalls(messages) {
|
|
174
|
+
if (!Array.isArray(messages)) return messages;
|
|
175
|
+
for (const msg of messages) {
|
|
176
|
+
if (!msg || msg.role !== 'assistant' || !Array.isArray(msg.toolCalls)) continue;
|
|
177
|
+
for (const tc of msg.toolCalls) {
|
|
178
|
+
if (!tc || !tc.arguments || typeof tc.arguments !== 'object') continue;
|
|
179
|
+
tc.arguments = _dropCompactedPlaceholders(tc.arguments);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return messages;
|
|
183
|
+
}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// strip any trailing/orphaned tool_use|tool_result so provider.send sees a
|
|
6
6
|
// valid transcript instead of leaking the 400 to the user.
|
|
7
7
|
import { sanitizeToolPairs } from '../context-utils.mjs';
|
|
8
|
+
import { scrubCompactedPlaceholderToolCalls } from './stored-tool-args.mjs';
|
|
8
9
|
|
|
9
10
|
// Transcript pairing guard. Anthropic 400-rejects when an assistant message
|
|
10
11
|
// ends with tool_use blocks and the next message isn't tool results for
|
|
@@ -97,5 +98,9 @@ export function repairTranscriptBeforeProviderSend(messages, sessionId = null) {
|
|
|
97
98
|
messages.push(...sanitized);
|
|
98
99
|
}
|
|
99
100
|
_ensureTranscriptPairing(messages, sessionId);
|
|
101
|
+
// Pre-send invariant: no provider-visible assistant toolCall may carry a
|
|
102
|
+
// compacted `[mixdog compacted …]` placeholder body that looks like
|
|
103
|
+
// submittable patch input. Runs after pairing repair, still before send.
|
|
104
|
+
scrubCompactedPlaceholderToolCalls(messages);
|
|
100
105
|
return messages;
|
|
101
106
|
}
|
|
@@ -157,6 +157,10 @@ async function runRecallFastTrackForSession(session, messages, opts = {}) {
|
|
|
157
157
|
messages,
|
|
158
158
|
cwd: session?.cwd,
|
|
159
159
|
limit: hydrateLimit,
|
|
160
|
+
// Clear/manual-compact path: these rows are about to be summarized
|
|
161
|
+
// away, so skip the bounded 15s synchronous embedding-flush wait —
|
|
162
|
+
// kick the flush fire-and-forget (dense-search immediacy is moot here).
|
|
163
|
+
embedWait: false,
|
|
160
164
|
}, callerCtx, memoryTimeoutMs);
|
|
161
165
|
} catch (err) {
|
|
162
166
|
// Ingest failed (dead/timed-out memory runtime). The transcript is NOT
|
|
@@ -30,7 +30,7 @@ import { preDispatchDenyForSession } from './loop/pre-dispatch-deny.mjs';
|
|
|
30
30
|
import { executeTool } from './loop/tool-exec.mjs';
|
|
31
31
|
import { crossTurnSignature, crossTurnDedupStub, isEditProgressTool } from './loop/completion-guards.mjs';
|
|
32
32
|
import { getToolKind, isEagerDispatchable, parseNativeToolSearchPayload } from './loop/tool-helpers.mjs';
|
|
33
|
-
import { restoreToolCallBodyForId } from './loop/stored-tool-args.mjs';
|
|
33
|
+
import { restoreToolCallBodyForId, dropCompactedBodyArgsForId } from './loop/stored-tool-args.mjs';
|
|
34
34
|
|
|
35
35
|
export async function processToolBatch(ctx) {
|
|
36
36
|
const {
|
|
@@ -586,6 +586,17 @@ export async function processToolBatch(ctx) {
|
|
|
586
586
|
toolKind: 'error',
|
|
587
587
|
});
|
|
588
588
|
}
|
|
589
|
+
// Successful call: its compacted body/long args are never needed
|
|
590
|
+
// again. Drop any `[mixdog compacted …]` placeholder from the stored
|
|
591
|
+
// assistant tool_use so a prior apply_patch INPUT never surfaces a
|
|
592
|
+
// resubmittable placeholder patch body the model copies back verbatim
|
|
593
|
+
// (the patch guard catches it, but only after a wasted turn). Gated on
|
|
594
|
+
// full success + clean post-processing; the failed/post-fail paths run
|
|
595
|
+
// restoreToolCallBodyForId instead (mutually exclusive per call id).
|
|
596
|
+
// Cache-safe: assistantTurnMsg is not transmitted until the next send.
|
|
597
|
+
if (_postProcessOk && _executeOk && _resultKind === 'normal' && call?.id) {
|
|
598
|
+
dropCompactedBodyArgsForId(assistantTurnMsg, call.id);
|
|
599
|
+
}
|
|
589
600
|
// Soft-cancel after each tool: if close landed during execution,
|
|
590
601
|
// discard the rest of the batch and skip the next provider.send.
|
|
591
602
|
throwIfAborted();
|
|
@@ -278,9 +278,16 @@ function checkIntInRange(a, field, min, max, opts = {}) {
|
|
|
278
278
|
if (value < min) {
|
|
279
279
|
return `Error: builtin arg "${field}" must be >= ${min} (got ${value})`;
|
|
280
280
|
}
|
|
281
|
+
// Over-max is clamped to the cap instead of erroring: an over-large
|
|
282
|
+
// integer is an unambiguous "as much as allowed" request, not a shape
|
|
283
|
+
// violation worth a retry turn (mirrors the soft-cap clamp above).
|
|
284
|
+
// Under-min still errors — a negative where >=0 is required is a real
|
|
285
|
+
// mistake, not a saturating intent.
|
|
281
286
|
if (value > max) {
|
|
282
|
-
|
|
287
|
+
a[field] = max;
|
|
288
|
+
return null;
|
|
283
289
|
}
|
|
290
|
+
a[field] = value;
|
|
284
291
|
return null;
|
|
285
292
|
}
|
|
286
293
|
|
|
@@ -670,6 +677,8 @@ function guardList(a) {
|
|
|
670
677
|
return `Error: list arg "pattern" must be string or string[] (got ${describeType(a.pattern)})`;
|
|
671
678
|
}
|
|
672
679
|
if (hasOwn(a, 'head_limit') && a.head_limit !== undefined && a.head_limit !== null) {
|
|
680
|
+
const coerced = coerceIntegerString(a.head_limit);
|
|
681
|
+
if (coerced !== null) a.head_limit = coerced;
|
|
673
682
|
if (!isFiniteInt(a.head_limit)) {
|
|
674
683
|
return `Error: list arg "head_limit" must be a finite integer (got ${describeType(a.head_limit)})`;
|
|
675
684
|
}
|
|
@@ -695,6 +704,8 @@ function guardFind(a) {
|
|
|
695
704
|
return `Error: find arg "path" must be a string (got ${describeType(a.path)})`;
|
|
696
705
|
}
|
|
697
706
|
if (hasOwn(a, 'head_limit') && a.head_limit !== undefined && a.head_limit !== null) {
|
|
707
|
+
const coerced = coerceIntegerString(a.head_limit);
|
|
708
|
+
if (coerced !== null) a.head_limit = coerced;
|
|
698
709
|
if (!isFiniteInt(a.head_limit)) {
|
|
699
710
|
return `Error: find arg "head_limit" must be a finite integer (got ${describeType(a.head_limit)})`;
|
|
700
711
|
}
|
|
@@ -734,6 +745,8 @@ function guardGlob(a) {
|
|
|
734
745
|
}
|
|
735
746
|
}
|
|
736
747
|
if (hasOwn(a, 'head_limit') && a.head_limit !== undefined && a.head_limit !== null) {
|
|
748
|
+
const coerced = coerceIntegerString(a.head_limit);
|
|
749
|
+
if (coerced !== null) a.head_limit = coerced;
|
|
737
750
|
if (!isFiniteInt(a.head_limit)) {
|
|
738
751
|
return `Error: glob arg "head_limit" must be a finite integer (got ${describeType(a.head_limit)})`;
|
|
739
752
|
}
|
|
@@ -67,7 +67,7 @@ export const BUILTIN_TOOLS = [
|
|
|
67
67
|
name: 'shell',
|
|
68
68
|
title: 'Mixdog Shell',
|
|
69
69
|
annotations: { title: 'Mixdog Shell', readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true, compressible: true },
|
|
70
|
-
description: `Run programs or change system state. Set shell: powershell or bash. Not for reading, listing, or searching files. ${TOOL_ASYNC_EXECUTION_CONTRACT}`,
|
|
70
|
+
description: `Run programs or change system state. Set shell: powershell or bash. Not for reading, listing, or searching files. Batch independent commands: ;/&& in one call, or parallel calls in the same turn. ${TOOL_ASYNC_EXECUTION_CONTRACT}`,
|
|
71
71
|
inputSchema: {
|
|
72
72
|
type: 'object',
|
|
73
73
|
properties: {
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
getBackgroundTask,
|
|
20
20
|
} from '../../../../shared/background-tasks.mjs';
|
|
21
21
|
import { startChildGuardian } from '../../../../shared/child-guardian.mjs';
|
|
22
|
+
import { detachedSpawnOpts } from '../../../../shared/spawn-flags.mjs';
|
|
22
23
|
import {
|
|
23
24
|
getShellJobsDir,
|
|
24
25
|
shellJobStdoutPath,
|
|
@@ -828,9 +829,8 @@ function _startBackgroundShellJobImpl({ command, timeoutMs, workDir, mergeStderr
|
|
|
828
829
|
const child = spawn(shell, [wrappedTempPath], {
|
|
829
830
|
cwd: workDir,
|
|
830
831
|
env: scrubLoaderVars(scrubProviderSecrets({ ...spawnEnv })),
|
|
831
|
-
detached: true,
|
|
832
832
|
stdio: 'ignore',
|
|
833
|
-
|
|
833
|
+
...detachedSpawnOpts,
|
|
834
834
|
});
|
|
835
835
|
startChildGuardian({
|
|
836
836
|
childPid: child.pid,
|
|
@@ -9,7 +9,7 @@ export const CODE_GRAPH_TOOL_DEFS = [
|
|
|
9
9
|
properties: {
|
|
10
10
|
mode: { type: 'string', enum: ['overview', 'imports', 'dependents', 'related', 'impact', 'symbols', 'find_symbol', 'symbol_search', 'search', 'references', 'callers'], description: 'Repo-local graph operation.' },
|
|
11
11
|
files: { anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' }, minItems: 1 }], description: 'Source file(s); array fans out per file. `file` alias too.' },
|
|
12
|
-
symbols: { anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' }, minItems: 1 }], description: 'Identifier(s)/keyword(s); array batches in one call. `symbol` alias too.' },
|
|
12
|
+
symbols: { anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' }, minItems: 1 }], description: 'Identifier(s)/keyword(s); array batches in one call. `symbol` alias too. Required for callers/callees/references/find_symbol/symbol_search.' },
|
|
13
13
|
symbol: { type: 'string', description: 'Singular alias for symbols.' },
|
|
14
14
|
file: { type: 'string', description: 'Singular alias for files.' },
|
|
15
15
|
body: { type: 'boolean', description: 'Include body.' },
|
|
@@ -26,7 +26,7 @@ export const PATCH_TOOL_DEFS = [
|
|
|
26
26
|
name: 'apply_patch',
|
|
27
27
|
title: 'Mixdog Apply Patch',
|
|
28
28
|
annotations: { title: 'Mixdog Apply Patch', readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false, compressible: false, compressibleLossless: true },
|
|
29
|
-
description: 'Apply file patches. Prefer V4A envelope with exact current context.',
|
|
29
|
+
description: 'Apply file patches. Prefer V4A envelope with exact current context. Batch independent edits into ONE patch (multiple file blocks).',
|
|
30
30
|
freeformDescription: APPLY_PATCH_FREEFORM_DESCRIPTION,
|
|
31
31
|
freeform: {
|
|
32
32
|
type: 'grammar',
|
|
@@ -129,15 +129,19 @@ export function wrapPowerShellWithCwdProbe(command, stateFile) {
|
|
|
129
129
|
// a failing native call $LASTEXITCODE holds its real code (case3 → 7).
|
|
130
130
|
//
|
|
131
131
|
// The `if (-not $?)` check MUST be the first statement after the command;
|
|
132
|
-
// any intervening statement resets $?. Terminating errors (`throw
|
|
133
|
-
// catch → $__ec = 1.
|
|
132
|
+
// any intervening statement resets $?. Terminating errors (`throw`,
|
|
133
|
+
// `-ErrorAction Stop`, .NET exceptions) skip to catch → $__ec = 1. A
|
|
134
|
+
// *caught* terminating error is NOT auto-written to the error stream, so
|
|
135
|
+
// without re-emitting it the caller only sees `[exit code: 1] (no output)`.
|
|
136
|
+
// Re-surface the ErrorRecord on stderr so the failure cause reaches the
|
|
137
|
+
// result envelope. The cwd probe runs in finally regardless, and we exit
|
|
134
138
|
// with $__ec so the probe never masks the observed code.
|
|
135
139
|
return '$global:LASTEXITCODE = 0\n'
|
|
136
140
|
+ '$__ec = 0\n'
|
|
137
141
|
+ 'try {\n'
|
|
138
142
|
+ `${command}\n`
|
|
139
143
|
+ ' if (-not $?) { $__ec = if ($LASTEXITCODE) { $LASTEXITCODE } else { 1 } }\n'
|
|
140
|
-
+ '} catch { $__ec = 1 }\n'
|
|
144
|
+
+ '} catch { [Console]::Error.WriteLine(($_ | Out-String).Trim()); $__ec = 1 }\n'
|
|
141
145
|
+ `finally { (Get-Location).Path | Out-File -Encoding utf8 ${qFile} }\n`
|
|
142
146
|
+ 'exit $__ec';
|
|
143
147
|
}
|