mixdog 0.9.128 → 0.9.129

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.128",
3
+ "version": "0.9.129",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Standalone mixdog coding-agent CLI/TUI workspace.",
package/src/app.mjs CHANGED
@@ -86,6 +86,8 @@ export async function run(argv = [], classifiedInvocation = null) {
86
86
  model: opts.model,
87
87
  effort: opts.effort,
88
88
  fast: opts.fast,
89
+ webSearch: opts.webSearch,
90
+ memory: opts.memory,
89
91
  json: opts.json,
90
92
  cwd: process.cwd(),
91
93
  });
@@ -4,7 +4,7 @@ const FLAG_OPTIONS = new Set([
4
4
  '--web-search', '--memory', '--json',
5
5
  ]);
6
6
  const EXEC_UNSUPPORTED_FLAGS = new Set([
7
- '--readonly', '--remote', '--onboarding', '--web-search', '--memory',
7
+ '--readonly', '--remote', '--onboarding',
8
8
  ]);
9
9
  const HEADLESS_WORKFLOW_ERROR = 'option --workflow is not supported for mixdog exec';
10
10
 
@@ -90,6 +90,8 @@ function createJsonLifecycle({
90
90
  effort,
91
91
  fast,
92
92
  cwd,
93
+ webSearch = false,
94
+ memory = false,
93
95
  }) {
94
96
  let threadId = `exec_${randomUUID().replace(/-/g, '')}`;
95
97
  const turnId = 'turn_1';
@@ -143,6 +145,8 @@ function createJsonLifecycle({
143
145
  tool_mode: 'full',
144
146
  approval_mode: 'implicit',
145
147
  delegation: false,
148
+ web_search: webSearch === true,
149
+ memory: memory === true,
146
150
  },
147
151
  }, turnStartedAt);
148
152
  emit({
@@ -540,6 +544,8 @@ export async function runHeadlessExec({
540
544
  model,
541
545
  effort,
542
546
  fast,
547
+ webSearch = false,
548
+ memory = false,
543
549
  json = false,
544
550
  cwd = process.cwd(),
545
551
  write = (text) => stdout.write(text),
@@ -571,6 +577,8 @@ export async function runHeadlessExec({
571
577
  effort,
572
578
  fast,
573
579
  cwd,
580
+ webSearch,
581
+ memory,
574
582
  }) : null;
575
583
  let boundary = null;
576
584
  let runtime = null;
@@ -602,6 +610,12 @@ export async function runHeadlessExec({
602
610
  const createRuntime = runtimeFactory || (
603
611
  await import('./mixdog-session-runtime.mjs')
604
612
  ).createMixdogSessionRuntime;
613
+ // Headless defaults: web research and memory tools stay OFF unless the
614
+ // caller opts in via --web-search / --memory. Delegation is already
615
+ // disallowed below, completing the solo surface. The per-process
616
+ // MIXDOG_FEATURE_* overrides are the runtime's canonical switches.
617
+ process.env.MIXDOG_FEATURE_WEB_SEARCH = webSearch === true ? '1' : '0';
618
+ process.env.MIXDOG_FEATURE_MEMORY = memory === true ? '1' : '0';
605
619
  runtime = await createRuntime({
606
620
  provider,
607
621
  model,
@@ -1009,6 +1009,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1009
1009
  // server-side cache prefix stable.
1010
1010
  const _assistantTurnMsg = attachAssistantTranscriptMetadata({
1011
1011
  role: 'assistant',
1012
+ createdAt: Date.now(),
1012
1013
  // Sub-agent tool-call turns carry only mid-turn preamble in
1013
1014
  // response.content (the real result rides the later final-answer
1014
1015
  // turn). Blank it so it never accumulates as input tokens.
@@ -1,7 +1,6 @@
1
1
  import { estimateTokens } from '../context-utils.mjs';
2
2
 
3
3
  const CONVERSATION_LINE_CHARS = 800;
4
- const WORKING_FILE_CAP = 20;
5
4
  const TOOL_OUTCOME_CHARS = 80;
6
5
 
7
6
  function textOf(m) {
@@ -42,13 +41,15 @@ function normalizeWorkingPath(value, cwd) {
42
41
 
43
42
  function pathsFromTool(name, args) {
44
43
  const out = [];
45
- const rawPath = args.path;
46
- const candidates = Array.isArray(rawPath)
47
- ? rawPath
48
- : String(rawPath || '').split(',');
49
- for (const item of candidates) {
50
- const value = typeof item === 'string' ? item.trim() : '';
51
- if (isFilePath(value)) out.push(value);
44
+ const rawPaths = [args.path, name === 'code_graph' ? args.files : null];
45
+ for (const rawPath of rawPaths) {
46
+ const candidates = Array.isArray(rawPath)
47
+ ? rawPath
48
+ : String(rawPath || '').split(',');
49
+ for (const item of candidates) {
50
+ const value = typeof item === 'string' ? item.trim() : '';
51
+ if (isFilePath(value)) out.push(value);
52
+ }
52
53
  }
53
54
  if (name === 'apply_patch' && typeof args.patch === 'string') {
54
55
  for (const line of args.patch.split('\n')) {
@@ -59,28 +60,206 @@ function pathsFromTool(name, args) {
59
60
  return out;
60
61
  }
61
62
 
62
- export function collectWorkingFiles(messages, cap = WORKING_FILE_CAP, { cwd } = {}) {
63
- const limit = Math.max(1, Math.floor(Number(cap) || WORKING_FILE_CAP));
64
- const seen = new Set();
65
- const out = [];
66
- for (let i = (messages || []).length - 1; i >= 0 && out.length < limit; i -= 1) {
63
+ function normalizeEventTime(value) {
64
+ if (value == null || value === '') return null;
65
+ const millis = typeof value === 'number' ? value : Date.parse(String(value));
66
+ if (!Number.isFinite(millis) || millis <= 0) return null;
67
+ return new Date(millis).toISOString();
68
+ }
69
+
70
+ function newerEventTime(left, right) {
71
+ const leftMs = left ? Date.parse(left) : 0;
72
+ const rightMs = right ? Date.parse(right) : 0;
73
+ return rightMs > leftMs ? right : left;
74
+ }
75
+
76
+ function toolEventTime(message, toolCall, resultMessage, fallback) {
77
+ const candidates = [
78
+ resultMessage?.createdAt,
79
+ resultMessage?.timestamp,
80
+ toolCall?.createdAt,
81
+ toolCall?.timestamp,
82
+ message?.createdAt,
83
+ message?.timestamp,
84
+ message?.meta?.createdAt,
85
+ fallback,
86
+ ];
87
+ for (const candidate of candidates) {
88
+ const normalized = normalizeEventTime(candidate);
89
+ if (normalized) return normalized;
90
+ }
91
+ return null;
92
+ }
93
+
94
+ function parseWorkingEntry(value) {
95
+ const raw = String(value || '').trim();
96
+ const metadata = /\s+\[([^\]]+)\]\s*$/.exec(raw);
97
+ const path = metadata ? raw.slice(0, metadata.index).trim() : raw;
98
+ const fields = {};
99
+ for (const part of String(metadata?.[1] || '').split(';')) {
100
+ const hit = /^\s*(editedAt|seenAt)=(.+?)\s*$/.exec(part);
101
+ if (hit) fields[hit[1]] = normalizeEventTime(hit[2]);
102
+ }
103
+ return {
104
+ path,
105
+ editedAt: fields.editedAt || null,
106
+ seenAt: fields.seenAt || null,
107
+ };
108
+ }
109
+
110
+ function priorWorkingFileGroups(text, cwd) {
111
+ const modified = [];
112
+ const referenced = [];
113
+ let section = null;
114
+ for (const raw of String(text || '').split('\n')) {
115
+ const line = raw.trim();
116
+ if (line === '## Working files') {
117
+ section = 'referenced';
118
+ continue;
119
+ }
120
+ if (!section) continue;
121
+ if (line === '### Modified') {
122
+ section = 'modified';
123
+ continue;
124
+ }
125
+ if (line === '### Referenced') {
126
+ section = 'referenced';
127
+ continue;
128
+ }
129
+ if (/^##\s+/.test(line) || /^<\/?prior-compacted-context>$/.test(line)) {
130
+ section = null;
131
+ continue;
132
+ }
133
+ const hit = /^-\s+(.+)$/.exec(line);
134
+ if (!hit || hit[1] === '(none)' || /^\+\d+\s+omitted$/.test(hit[1])) continue;
135
+ const entry = parseWorkingEntry(hit[1]);
136
+ entry.path = normalizeWorkingPath(entry.path, cwd);
137
+ if (!isFilePath(entry.path)) continue;
138
+ if (section === 'modified') {
139
+ modified.push(entry);
140
+ } else {
141
+ referenced.push(entry);
142
+ }
143
+ }
144
+ return { modified, referenced };
145
+ }
146
+
147
+ function mergeWorkingEntries(current, prior, limit, cwd) {
148
+ const entries = new Map();
149
+ let order = 0;
150
+ const touch = (raw, kind) => {
151
+ const path = normalizeWorkingPath(raw?.path, cwd);
152
+ if (!isFilePath(path)) return;
153
+ const key = path.toLowerCase();
154
+ let entry = entries.get(key);
155
+ if (!entry) {
156
+ entry = {
157
+ path,
158
+ editedAt: null,
159
+ seenAt: null,
160
+ modified: false,
161
+ order: order++,
162
+ };
163
+ entries.set(key, entry);
164
+ }
165
+ entry.seenAt = newerEventTime(entry.seenAt, normalizeEventTime(raw?.seenAt));
166
+ if (kind === 'modified') {
167
+ entry.modified = true;
168
+ entry.editedAt = newerEventTime(entry.editedAt, normalizeEventTime(raw?.editedAt));
169
+ }
170
+ };
171
+ for (const entry of current.modified) touch(entry, 'modified');
172
+ for (const entry of current.referenced) touch(entry, 'referenced');
173
+ for (const entry of prior.modified) touch(entry, 'modified');
174
+ for (const entry of prior.referenced) touch(entry, 'referenced');
175
+ const sorted = [...entries.values()].sort((left, right) => {
176
+ const leftTime = Date.parse(left.editedAt || left.seenAt || '') || 0;
177
+ const rightTime = Date.parse(right.editedAt || right.seenAt || '') || 0;
178
+ return rightTime - leftTime || left.order - right.order;
179
+ });
180
+ const modified = sorted.filter((entry) => entry.modified);
181
+ const referenced = sorted.filter((entry) => !entry.modified);
182
+ if (!Number.isFinite(limit)) return { modified, referenced };
183
+ const keptModified = modified.slice(0, limit);
184
+ return {
185
+ modified: keptModified,
186
+ referenced: referenced.slice(0, Math.max(0, limit - keptModified.length)),
187
+ };
188
+ }
189
+
190
+ export function collectWorkingFileGroups(messages, cap = Number.POSITIVE_INFINITY, {
191
+ cwd,
192
+ previousSummary,
193
+ now = Date.now(),
194
+ } = {}) {
195
+ const numericCap = Number(cap);
196
+ const limit = Number.isFinite(numericCap) && numericCap > 0
197
+ ? Math.floor(numericCap)
198
+ : Number.POSITIVE_INFINITY;
199
+ const results = indexToolResults(messages);
200
+ const resultMessages = indexToolResultMessages(messages);
201
+ const currentModified = [];
202
+ const currentReferenced = [];
203
+ const supported = new Set(['read', 'apply_patch', 'grep', 'glob', 'find', 'code_graph', 'list']);
204
+ for (let i = (messages || []).length - 1; i >= 0; i -= 1) {
67
205
  const m = messages[i];
68
206
  if (m?.role !== 'assistant' || !Array.isArray(m.toolCalls)) continue;
69
- for (let j = m.toolCalls.length - 1; j >= 0 && out.length < limit; j -= 1) {
207
+ for (let j = m.toolCalls.length - 1; j >= 0; j -= 1) {
70
208
  const tc = m.toolCalls[j];
71
209
  const name = toolName(tc);
72
- if (!['read', 'apply_patch', 'grep', 'glob', 'find'].includes(name)) continue;
210
+ if (!supported.has(name)) continue;
211
+ const resultMessage = resultMessages.get(String(tc.id || tc.toolCallId || ''));
212
+ if (name === 'apply_patch') {
213
+ const output = results.get(String(tc.id || tc.toolCallId || ''));
214
+ if (/Error:|failed|rejected/i.test(String(output || ''))) continue;
215
+ }
216
+ const eventTime = toolEventTime(m, tc, resultMessage, now);
73
217
  for (const p of pathsFromTool(name, parseArgs(tc))) {
74
- const normalized = normalizeWorkingPath(p, cwd);
75
- const key = normalized.toLowerCase();
76
- if (seen.has(key)) continue;
77
- seen.add(key);
78
- out.push(normalized);
79
- if (out.length >= limit) break;
218
+ const path = normalizeWorkingPath(p, cwd);
219
+ if (!path) continue;
220
+ if (name === 'apply_patch') {
221
+ currentModified.push({
222
+ path,
223
+ editedAt: eventTime,
224
+ seenAt: eventTime,
225
+ });
226
+ } else {
227
+ currentReferenced.push({
228
+ path,
229
+ editedAt: null,
230
+ seenAt: eventTime,
231
+ });
232
+ }
80
233
  }
81
234
  }
82
235
  }
83
- return out;
236
+ const prior = priorWorkingFileGroups(previousSummary, cwd);
237
+ return mergeWorkingEntries({
238
+ modified: currentModified,
239
+ referenced: currentReferenced,
240
+ }, prior, limit, cwd);
241
+ }
242
+
243
+ export function collectWorkingFiles(messages, cap = Number.POSITIVE_INFINITY, options = {}) {
244
+ const groups = collectWorkingFileGroups(messages, cap, options);
245
+ return [...groups.modified, ...groups.referenced].map((entry) => entry.path);
246
+ }
247
+
248
+ export function stripWorkingFileSections(text) {
249
+ const out = [];
250
+ let skipping = false;
251
+ for (const raw of String(text || '').split('\n')) {
252
+ const line = raw.trim();
253
+ if (line === '## Working files') {
254
+ skipping = true;
255
+ continue;
256
+ }
257
+ if (skipping && (/^##\s+/.test(line) || /^<\/?prior-compacted-context>$/.test(line))) {
258
+ skipping = false;
259
+ }
260
+ if (!skipping) out.push(raw);
261
+ }
262
+ return out.join('\n').replace(/\n{3,}/g, '\n\n');
84
263
  }
85
264
 
86
265
  function indexToolResults(messages) {
@@ -93,6 +272,16 @@ function indexToolResults(messages) {
93
272
  return map;
94
273
  }
95
274
 
275
+ function indexToolResultMessages(messages) {
276
+ const map = new Map();
277
+ for (const message of messages || []) {
278
+ if (message?.role !== 'tool') continue;
279
+ const id = String(message.toolCallId || message.tool_call_id || '');
280
+ if (id) map.set(id, message);
281
+ }
282
+ return map;
283
+ }
284
+
96
285
  function patchTarget(args) {
97
286
  if (isFilePath(args.path)) return String(args.path).trim();
98
287
  if (typeof args.patch === 'string') {
@@ -215,7 +404,31 @@ export function composeRecallHandoff({
215
404
  parts.push('', '## Tool results', ...toolLines);
216
405
  }
217
406
  parts.push('', '## Working files');
218
- parts.push(...(workingFiles.length ? workingFiles.map((p) => `- ${p}`) : ['- (none)']));
407
+ const groups = Array.isArray(workingFiles)
408
+ ? {
409
+ modified: [],
410
+ referenced: workingFiles.map((entry) => (
411
+ typeof entry === 'string' ? { path: entry, seenAt: null } : entry
412
+ )),
413
+ }
414
+ : {
415
+ modified: Array.isArray(workingFiles?.modified) ? workingFiles.modified : [],
416
+ referenced: Array.isArray(workingFiles?.referenced) ? workingFiles.referenced : [],
417
+ };
418
+ const formatEntry = (entry, modified) => {
419
+ const normalized = typeof entry === 'string' ? { path: entry } : entry;
420
+ const fields = [];
421
+ if (modified) fields.push(`editedAt=${normalized?.editedAt || 'unknown'}`);
422
+ fields.push(`seenAt=${normalized?.seenAt || normalized?.editedAt || 'unknown'}`);
423
+ return `- ${normalized?.path || ''} [${fields.join('; ')}]`;
424
+ };
425
+ if (groups.modified.length) {
426
+ parts.push('### Modified', ...groups.modified.map((entry) => formatEntry(entry, true)));
427
+ }
428
+ if (groups.referenced.length) {
429
+ parts.push('### Referenced', ...groups.referenced.map((entry) => formatEntry(entry, false)));
430
+ }
431
+ if (!groups.modified.length && !groups.referenced.length) parts.push('- (none)');
219
432
  return parts.join('\n');
220
433
  }
221
434
 
@@ -240,8 +453,48 @@ export function fitRecallHandoffText(text, maxTokens) {
240
453
  candidate = `${prefix}${kept.join('\n')}${suffix}`;
241
454
  }
242
455
  if (estimateTokens(candidate) > cap && start >= lines.length) {
243
- const withoutTools = candidate.replace(/\n## Tool results\n[\s\S]*?(?=\n## Working files)/, '\n');
244
- if (estimateTokens(withoutTools) <= cap) return withoutTools;
456
+ candidate = candidate.replace(/\n## Tool results\n[\s\S]*?(?=\n## Working files)/, '\n');
457
+ }
458
+ if (estimateTokens(candidate) <= cap) return candidate;
459
+ const rows = candidate.split('\n');
460
+ const referencedAt = rows.findIndex((line) => line.trim() === '### Referenced');
461
+ if (referencedAt < 0) return candidate;
462
+ let referencedEnd = referencedAt + 1;
463
+ while (referencedEnd < rows.length && !/^#{2,3}\s+/.test(rows[referencedEnd].trim())) {
464
+ referencedEnd += 1;
465
+ }
466
+ const references = rows
467
+ .slice(referencedAt + 1, referencedEnd)
468
+ .filter((line) => /^-\s+/.test(line) && !/^\-\s+\+\d+\s+omitted$/.test(line));
469
+ const prefixRows = rows.slice(0, referencedAt + 1);
470
+ const suffixRows = rows.slice(referencedEnd);
471
+ let lo = 0;
472
+ let hi = references.length;
473
+ let best = -1;
474
+ while (lo <= hi) {
475
+ const mid = Math.floor((lo + hi) / 2);
476
+ const omitted = references.length - mid;
477
+ const next = [
478
+ ...prefixRows,
479
+ ...references.slice(0, mid),
480
+ ...(omitted > 0 ? [`- +${omitted} omitted`] : []),
481
+ ...suffixRows,
482
+ ].join('\n');
483
+ if (estimateTokens(next) <= cap) {
484
+ best = mid;
485
+ lo = mid + 1;
486
+ } else {
487
+ hi = mid - 1;
488
+ }
489
+ }
490
+ if (best >= 0) {
491
+ const omitted = references.length - best;
492
+ return [
493
+ ...prefixRows,
494
+ ...references.slice(0, best),
495
+ ...(omitted > 0 ? [`- +${omitted} omitted`] : []),
496
+ ...suffixRows,
497
+ ].join('\n');
245
498
  }
246
499
  return candidate;
247
500
  }
@@ -51,11 +51,12 @@ import {
51
51
  import { buildPostCompactFileAttachment } from './file-reattach.mjs';
52
52
  import {
53
53
  collectToolOutcomeLines,
54
- collectWorkingFiles,
54
+ collectWorkingFileGroups,
55
55
  composeRecallHandoff,
56
56
  fitRecallHandoffText,
57
57
  conversationLinesFromMemoryText,
58
58
  excludeTailFromConversation,
59
+ stripWorkingFileSections,
59
60
  } from './handoff.mjs';
60
61
 
61
62
  // Post-compact file re-attachment (claude-code parity): re-inject fresh reads
@@ -839,22 +840,38 @@ function _recallFastTrackCompactMessages(messages, budgetTokens, opts = {}) {
839
840
  const recallRoom = (Number.isFinite(recallTokenCap) && recallTokenCap > 0)
840
841
  ? Math.min(recallRoomUncapped, Math.max(512, recallTokenCap - tailTokens))
841
842
  : recallRoomUncapped;
842
- const toolLines = collectToolOutcomeLines(live);
843
- const workingFiles = collectWorkingFiles(live, 20, { cwd: opts.cwd });
843
+ const priorWithoutWorkingFiles = stripWorkingFileSections(recallFit.prior);
844
+ const priorLines = new Set(
845
+ priorWithoutWorkingFiles
846
+ .split('\n')
847
+ .map((line) => line.trim())
848
+ .filter(Boolean),
849
+ );
850
+ const toolLines = collectToolOutcomeLines(live)
851
+ .filter((line) => !priorLines.has(String(line || '').trim()));
852
+ const workingFiles = collectWorkingFileGroups(live, undefined, {
853
+ cwd: opts.cwd,
854
+ previousSummary: recallFit.prior,
855
+ now: Date.now(),
856
+ });
844
857
  const conversationLines = excludeTailFromConversation(
845
858
  conversationLinesFromMemoryText(recallFit.recall),
846
859
  recallTail,
847
- );
860
+ ).filter((line) => !priorLines.has(String(line || '').trim()));
848
861
  const composedRecall = composeRecallHandoff({
849
862
  sessionId: opts.sessionId || '',
850
863
  conversationLines,
851
864
  toolLines,
852
865
  workingFiles,
853
866
  });
854
- const fittedRecall = fitRecallHandoffText(composedRecall, Math.max(256, recallRoom - 400));
855
- const priorPart = conversationLines.length > 0
856
- ? ''
857
- : recallFit.prior;
867
+ const priorRoom = priorWithoutWorkingFiles
868
+ ? Math.max(256, Math.floor((recallRoom - 200) * 0.55))
869
+ : 0;
870
+ const currentRoom = Math.max(256, recallRoom - priorRoom - 200);
871
+ const fittedRecall = fitRecallHandoffText(composedRecall, currentRoom);
872
+ const priorPart = priorWithoutWorkingFiles
873
+ ? fitRecallHandoffText(priorWithoutWorkingFiles, priorRoom)
874
+ : '';
858
875
  const summaryMessage = fitRecallFastTrackSummaryMessage(
859
876
  oldHistory,
860
877
  fittedRecall,
@@ -911,7 +928,7 @@ function _recallFastTrackCompactMessages(messages, budgetTokens, opts = {}) {
911
928
  recallEmpty: !recallFit.recall,
912
929
  priorEmpty: !recallFit.prior,
913
930
  recallTruncatedInSummary: !!recallFit.recall && !summaryContent.includes(recallFit.recall),
914
- priorTruncatedInSummary: !!recallFit.prior && !summaryContent.includes(recallFit.prior),
931
+ priorTruncatedInSummary: !!priorPart && !summaryContent.includes(priorPart),
915
932
  tailTruncated: recallTail.some((m) => messageContentHasMarker(m, RECALL_TAIL_TRUNCATION_MARKER) || messageContentHasMarker(m, RECALL_TAIL_SHORT_TRUNCATION_MARKER)),
916
933
  fileReattached: reattach.reattached,
917
934
  tailOptions: recallTailOpts,
@@ -61,7 +61,7 @@ import {
61
61
  import { normalizeOutputPath } from './path-utils.mjs';
62
62
  import { normalizeErrorMessage } from './path-diagnostics.mjs';
63
63
  import { invalidateBuiltinResultCache } from './cache-layers.mjs';
64
- import { scrubLoaderVars, scrubProviderSecrets, scrubRuntimeRootVars } from '../env-scrub.mjs';
64
+ import { applyShellEgressPolicy, scrubLoaderVars, scrubProviderSecrets, scrubRuntimeRootVars } from '../env-scrub.mjs';
65
65
  import { resourceAdmission } from '../../../../shared/resource-admission.mjs';
66
66
  import {
67
67
  findPathExecutable,
@@ -549,6 +549,7 @@ export async function executeBashTool(args, workDir, options = {}) {
549
549
  scrubProviderSecrets(spawnEnv);
550
550
  scrubLoaderVars(spawnEnv);
551
551
  scrubRuntimeRootVars(spawnEnv);
552
+ applyShellEgressPolicy(spawnEnv);
552
553
  let wrappedCommand;
553
554
  let _teePlan = null;
554
555
  let execShell = shell;
@@ -108,7 +108,14 @@ export function describeShellStartupPolicy({
108
108
  } = {}) {
109
109
  const { available, unavailable } = detectPathCapabilities(pathOptions);
110
110
  const resolvedShell = shell || basename(String(resolveShellFor('default')?.shell || 'unknown'));
111
- return `- Shell startup environment: OS=${os}; shell=${resolvedShell}; available=${available.join(', ') || 'none'}; unavailable=${unavailable.join(', ') || 'none'}. For shell commands, treat every unavailable entry as absent. Invoke one only if the same command first installs it or exposes it on PATH.`;
111
+ // Session-variable capability line: when the process-level egress block
112
+ // is active (mixdog exec default pins MIXDOG_FEATURE_WEB_SEARCH=0 and
113
+ // shell children get a dead proxy), say so here — the model must not
114
+ // spend calls attempting web access that is guaranteed to fail.
115
+ const network = String(process.env.MIXDOG_FEATURE_WEB_SEARCH || '') === '0'
116
+ ? ' network=offline (external egress blocked for every command — curl/wget/pip/apt/git included; only loopback services are reachable; never attempt web access).'
117
+ : '';
118
+ return `- Shell startup environment: OS=${os}; shell=${resolvedShell}; available=${available.join(', ') || 'none'}; unavailable=${unavailable.join(', ') || 'none'}.${network} For shell commands, treat every unavailable entry as absent. Invoke one only if the same command first installs it or exposes it on PATH.`;
112
119
  }
113
120
 
114
121
  export function appendShellStartupPolicy(rules, tools, options = {}) {
@@ -75,6 +75,33 @@ const SECRET_EXACT = new Set([
75
75
  export function scrubLoaderVars(env) {
76
76
  if (!env || typeof env !== 'object') return env;
77
77
  for (const k of LOADER_VARS) delete env[k];
78
+ return _continueScrubLoaderVars(env);
79
+ }
80
+
81
+ // Headless "web off" reaches shell children too: `mixdog exec` pins
82
+ // MIXDOG_FEATURE_WEB_SEARCH=0 (default; --web-search opts back in) and every
83
+ // model-spawned subprocess then receives the standard proxy variables pointed
84
+ // at a dead loopback endpoint. curl/wget/git-http/pip/apt honor these and
85
+ // fail fast on public hosts, while NO_PROXY keeps loopback traffic
86
+ // (task-local servers) working. Raw-socket clients bypass proxy variables —
87
+ // this is a uniform egress policy for the common tooling path, not a sandbox.
88
+ // The runtime's own provider calls are unaffected: this mutates only the
89
+ // child spawn env, never process.env.
90
+ const SHELL_EGRESS_DEAD_PROXY = 'http://127.0.0.1:1';
91
+ const SHELL_EGRESS_PROXY_VARS = ['HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'FTP_PROXY', 'RSYNC_PROXY'];
92
+ export function applyShellEgressPolicy(env) {
93
+ if (!env || typeof env !== 'object') return env;
94
+ if (String(process.env.MIXDOG_FEATURE_WEB_SEARCH ?? '') !== '0') return env;
95
+ for (const name of SHELL_EGRESS_PROXY_VARS) {
96
+ env[name] = SHELL_EGRESS_DEAD_PROXY;
97
+ env[name.toLowerCase()] = SHELL_EGRESS_DEAD_PROXY;
98
+ }
99
+ env.NO_PROXY = 'localhost,127.0.0.1,::1';
100
+ env.no_proxy = env.NO_PROXY;
101
+ return env;
102
+ }
103
+
104
+ function _continueScrubLoaderVars(env) {
78
105
  // Wildcard sweep: the exact-name list covers the common loader vars but
79
106
  // the DYLD_/LD_ families have many siblings (DYLD_FRAMEWORK_PATH,
80
107
  // DYLD_FALLBACK_LIBRARY_PATH, LD_AUDIT, LD_BIND_NOW, …). Delete every
@@ -1,26 +1,26 @@
1
1
  {
2
- "version": "0.1.10",
2
+ "version": "0.1.11",
3
3
  "_comment": "Synced from immutable graph-v release assets.",
4
4
  "assets": {
5
5
  "darwin-arm64": {
6
- "url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.10/mixdog-graph-darwin-arm64",
7
- "sha256": "f73d25d6136c4776e38c79de7b6caa84b345c75a9a73a7696a88dd326c27041a"
6
+ "url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.11/mixdog-graph-darwin-arm64",
7
+ "sha256": "b606149747aa4106f3648d07d16b014f4c7c4b47d5b526eb29832aa3dba01180"
8
8
  },
9
9
  "darwin-x64": {
10
- "url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.10/mixdog-graph-darwin-x64",
11
- "sha256": "62e3a5643b952507223f0a776ca2fdbeaa64a0dde4249c917bcf69c73f42904a"
10
+ "url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.11/mixdog-graph-darwin-x64",
11
+ "sha256": "196f10b3d4216995efc41a96ab38c09c0e27676b14f459e34e30990bb35f2c68"
12
12
  },
13
13
  "linux-arm64": {
14
- "url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.10/mixdog-graph-linux-arm64",
15
- "sha256": "7b145406391fb9d231e3ac561fe894a257f00e39571d879756310109f2b77630"
14
+ "url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.11/mixdog-graph-linux-arm64",
15
+ "sha256": "70a1e0c591db4a3a96070cf490c412186f84e9897690307935679a55ae8f69b8"
16
16
  },
17
17
  "linux-x64": {
18
- "url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.10/mixdog-graph-linux-x64",
19
- "sha256": "fc55bcec32f832b0e85a6cb9ebc908e4e9aae3a664b060442eef4d17a4e4ed14"
18
+ "url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.11/mixdog-graph-linux-x64",
19
+ "sha256": "0936a3dc6d9ee4a1e97fe57f1ac5f1f62db89435cc199c6303c9fce3e1f29f05"
20
20
  },
21
21
  "win32-x64": {
22
- "url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.10/mixdog-graph-win32-x64.exe",
23
- "sha256": "5f4fc781ce2867367d86063c5729d43bcfb0d71cc18226191d46957046ceeb1b"
22
+ "url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.11/mixdog-graph-win32-x64.exe",
23
+ "sha256": "8d63891c2e15c13f894023f59994a45e54f64cca72c7098cc09069dce700bd43"
24
24
  }
25
25
  }
26
26
  }