mixdog 0.9.175 → 1.0.0

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 (44) hide show
  1. package/package.json +1 -1
  2. package/scripts/context-renewal-live-check.mjs +2 -1
  3. package/scripts/plan-runtime-release-tag.mjs +57 -0
  4. package/src/defaults/skills/xlsx/SKILL.md +1 -0
  5. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +7 -12
  6. package/src/runtime/agent/orchestrator/providers/anthropic-api-transport.mjs +6 -1
  7. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-request.mjs +2 -0
  8. package/src/runtime/agent/orchestrator/providers/cursor.mjs +20 -2
  9. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +3 -0
  10. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +2 -0
  11. package/src/runtime/agent/orchestrator/runtime-core/goal-tasks.mjs +12 -1
  12. package/src/runtime/agent/orchestrator/session/result-classification.mjs +1 -1
  13. package/src/runtime/agent/orchestrator/session/transcript-restore/tool-result-status.mjs +7 -3
  14. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/benign-exit.mjs +7 -3
  15. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +1 -0
  16. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +39 -3
  17. package/src/runtime/memory/lib/runtime-fetcher.mjs +94 -23
  18. package/src/runtime/office/capabilities-catalog.mjs +2 -0
  19. package/src/runtime/office/capabilities-signatures.mjs +12 -6
  20. package/src/runtime/office/capabilities.mjs +30 -10
  21. package/src/runtime/office/core/office-actions-open.mjs +1 -6
  22. package/src/runtime/office/core/office-sessions.mjs +9 -0
  23. package/src/runtime/office/portable/portable-xlsx-operations.mjs +2 -49
  24. package/src/runtime/office/portable/xlsx-contract.mjs +37 -3
  25. package/src/runtime/office/tool-defs.mjs +2 -2
  26. package/src/runtime/shared/llm/cost.mjs +16 -4
  27. package/src/runtime/shared/llm/model-catalog-projection.mjs +3 -2
  28. package/src/runtime/shared/llm/model-catalog.mjs +48 -0
  29. package/src/runtime/shared/llm/model-pricing-rates.mjs +11 -4
  30. package/src/runtime/shared/llm/usage-accounting.mjs +12 -11
  31. package/src/runtime/shared/llm/usage-context.mjs +7 -0
  32. package/src/runtime/shared/llm/usage-ledger-repair.mjs +3 -1
  33. package/src/runtime/shared/llm/usage-ledger.mjs +1 -1
  34. package/src/runtime/shared/tool-card-model/card-status.mjs +7 -5
  35. package/src/runtime/shared/tool-card-model/git-result.mjs +10 -0
  36. package/src/runtime/shared/tool-card-model/terminal-status.mjs +6 -2
  37. package/src/runtime/shared/tool-card-model.golden-cases.mjs +2 -1
  38. package/src/runtime/shared/tool-result-summary.mjs +1 -0
  39. package/src/runtime/shared/tool-status.mjs +35 -2
  40. package/src/runtime/tidy/report.mjs +2 -2
  41. package/src/runtime/tidy/tool.mjs +6 -1
  42. package/src/tui/dist/index.mjs +88 -37
  43. package/src/tui/session/session-api/settings.mjs +16 -10
  44. package/src/vendor/statusline/src/gateway/route-meta.mjs +7 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mixdog",
3
- "version": "0.9.175",
3
+ "version": "1.0.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Standalone mixdog coding-agent CLI/TUI workspace.",
@@ -125,7 +125,8 @@ try {
125
125
  for (const spec of specs) {
126
126
  const dataDir = path.join(sandbox, spec.name);
127
127
  const db = path.join(dataDir, 'pgdata');
128
- const ctl = path.join(dataDir, 'runtime', 'runtime-pg16.4+pgvector-0.8.2', 'bin', 'pg_ctl.exe');
128
+ const runtimeKey = fs.readFileSync(path.join(dataDir, 'runtime', 'active-version'), 'utf8').trim();
129
+ const ctl = path.join(dataDir, 'runtime', `runtime-${runtimeKey}`, 'bin', 'pg_ctl.exe');
129
130
  if (process.platform === 'win32' && fs.existsSync(ctl) && fs.existsSync(path.join(db, 'postmaster.pid'))) {
130
131
  const stopped = spawnSync(ctl, ['-D', db, '-m', 'fast', '-w', 'stop'], { stdio: 'inherit' });
131
132
  if (stopped.status !== 0) cleanupFailed = true;
@@ -0,0 +1,57 @@
1
+ // Decides the release tag a memory runtime build publishes under.
2
+ //
3
+ // Published runtime assets are immutable: every app that shipped a manifest
4
+ // naming a tag verifies that tag's assets by size and sha256, so replacing an
5
+ // asset under a shipped tag breaks the next runtime install of all of those
6
+ // apps. A build that would replace an asset of a shipped tag (any tag at or
7
+ // below the bundled manifest's release_tag) moves to the next patch tag; the
8
+ // manifest sync then points new app builds at it.
9
+ //
10
+ // CLI: node scripts/plan-runtime-release-tag.mjs <requested-tag> <shipped-tag> <needed-assets-file> <published-assets-file>
11
+ // prints the tag to build. The asset files hold one asset name per line.
12
+ import { readFileSync } from 'node:fs';
13
+ import { pathToFileURL } from 'node:url';
14
+
15
+ const TAG_RE = /^runtime-v(\d+)\.(\d+)\.(\d+)$/;
16
+
17
+ function parseTag(tag) {
18
+ const match = TAG_RE.exec(String(tag ?? '').trim());
19
+ if (!match) throw new Error(`invalid runtime release tag: ${tag}`);
20
+ return match.slice(1).map(Number);
21
+ }
22
+
23
+ function compareTags(left, right) {
24
+ const a = parseTag(left);
25
+ const b = parseTag(right);
26
+ for (let index = 0; index < 3; index += 1) {
27
+ if (a[index] !== b[index]) return a[index] - b[index];
28
+ }
29
+ return 0;
30
+ }
31
+
32
+ export function planRuntimeReleaseTag({ requestedTag, shippedTag, neededAssets, publishedAssets }) {
33
+ const published = new Set(publishedAssets);
34
+ const replacesPublished = neededAssets.some((name) => published.has(name));
35
+ if (!replacesPublished || compareTags(requestedTag, shippedTag) > 0) return requestedTag;
36
+ const [major, minor, patch] = parseTag(shippedTag);
37
+ return `runtime-v${major}.${minor}.${patch + 1}`;
38
+ }
39
+
40
+ function readNames(path) {
41
+ return readFileSync(path, 'utf8')
42
+ .split(/\r?\n/)
43
+ .map((line) => line.trim())
44
+ .filter(Boolean);
45
+ }
46
+
47
+ if (import.meta.url === pathToFileURL(process.argv[1] || '').href) {
48
+ const [requestedTag, shippedTag, neededPath, publishedPath] = process.argv.slice(2);
49
+ process.stdout.write(
50
+ `${planRuntimeReleaseTag({
51
+ requestedTag,
52
+ shippedTag,
53
+ neededAssets: readNames(neededPath),
54
+ publishedAssets: readNames(publishedPath),
55
+ })}\n`
56
+ );
57
+ }
@@ -37,6 +37,7 @@ Excel proves the numbers and makes their meaning readable. Keep sourced inputs a
37
37
  1. Settle the table first: headers, one row per record, consistent units, numeric cells as numbers (not text), dates as ISO strings (both backends store `2026-09-30` as the Excel date under `yyyy-mm-dd`, so date arithmetic is `=D5-DATE(2026,9,22)`, not `DATEVALUE`), percentages as fractions (`0.15`, formatted `0.0%`). A figure written as text is stored as the number Excel types from it, on both backends — `"1,234"` under `#,##0`, `"15%"` as 0.15 under `0%` — and any other text stays exactly as written (`"1-2"`, `"007"`, `"1e3"`, `"TRUE"` never become a date, a 7, a 1000, or a truth value). Keep the same `design.content` model as the deck or document in the same package.
38
38
  2. For a report, design the reading order, sheet roles, column proportions, chart placement and intended screen/print use. Author with native cell, style, merge and chart operations by default. `compose_sheet` is an opt-in preset only when its table-plus-panel structure already fits; do not select it merely to save calls. Batch all known operations. Use `describe format:'xlsx' operation:<op>` when fields are unknown; cell styles belong in `properties`.
39
39
  3. Add sheets with `add_sheet`, then native ranges and formulas targeting `sheet`. Use `set_formula` for derived cells and `define_name` for reused inputs. Check representative formulas against the intended ranges before extending a model; clean recalculation proves evaluation, not correct modelling. A design specimen is optional, not required for every workbook.
40
+ If `create` fails, stop dependent `open`, `render`, and `finalize` calls: a failed creation is not an artifact. Correct the reported inputs and resume only from a successful `create` result.
40
41
  4. Raw tabular files (`.csv`, `.tsv`) take `set_range` with a 2D `values` array and `append_row`; they carry no styles, formulas, or multiple sheets.
41
42
  5. `finalize` recalculates and returns current rendered pages for review; add `auditProfile:'financial-model'` for a decision model. Read the actual images as in `report-design.md`, with concrete keep/fix observations about hierarchy, charts and print layout. Correct material issues and rerender, rather than filling a pass checklist. Complete with `design:{ reviewed:true, reviewToken:<current token>, critique:[{ page:1, verdict:'pass', note:<page-specific observations> }, ...] }`. This records agent review, not user approval. CSV/TSV needs no visual approval. Resolve validation failures against their original baseline, never by reopening as a new baseline.
42
43
 
@@ -4,6 +4,7 @@ import { countJsonNextCalls } from './tools/next-call-utils.mjs';
4
4
  import { parseGrepContextHeader, splitGrepLinePrefix } from './tools/builtin/grep-formatting.mjs';
5
5
  import { isReadOnlyNavigationMiss } from './session/result-classification.mjs';
6
6
  import { nonNegativeInt } from '../../shared/numbers.mjs';
7
+ import { isUserControlCancellation } from '../../shared/tool-status.mjs';
7
8
  import {
8
9
  appendAgentTrace,
9
10
  normalizeSessionId,
@@ -128,7 +129,7 @@ function traceAgentCompact({
128
129
  }
129
130
 
130
131
  const TOOL_ARG_KEYS = {
131
- read: ['path', 'offset', 'limit', 'line', 'context'],
132
+ read: ['file_path', 'path', 'offset', 'limit', 'line', 'context'],
132
133
  grep: ['pattern', 'path', 'glob', 'output_mode', 'head_limit', 'offset'],
133
134
  glob: ['pattern', 'path', 'head_limit', 'offset', 'sort'],
134
135
  find: ['query', 'path', 'head_limit'],
@@ -211,12 +212,11 @@ function summarizeToolArgs(toolName, args) {
211
212
  for (const key of keys) {
212
213
  if (Object.hasOwn(args, key)) out[key] = compactTraceArgValue(args[key], key);
213
214
  }
214
- for (const countKey of ['edits', 'writes']) {
215
+ // Arrays are clipped to 6 entries above; the count keeps batch-limit
216
+ // failures (more than 10 read/grep targets) diagnosable.
217
+ for (const countKey of ['edits', 'writes', 'file_path', 'path']) {
215
218
  if (Array.isArray(args[countKey])) out[`${countKey}_count`] = args[countKey].length;
216
219
  }
217
- if (toolName === 'read' && Array.isArray(args.path)) {
218
- out.path_count = args.path.length;
219
- }
220
220
  return Object.keys(out).length ? out : null;
221
221
  }
222
222
 
@@ -414,17 +414,12 @@ function classifyToolFailure(resultText, toolName) {
414
414
  if (/\[tool-input-validation\]/.test(text)) return 'schema/args';
415
415
  // These are emitted validation/cancellation contracts, not keywords found
416
416
  // somewhere in a quoted document, command output, or error preview.
417
- if (
418
- /^(?:Browser command interrupted by local user input\.|computer_session_aborted: queued command was cancelled before execution|computer_user_control_active:)/i.test(
419
- leading
420
- )
421
- )
422
- return 'expected-cancellation';
417
+ if (isUserControlCancellation(leading)) return 'expected-cancellation';
423
418
  if (
424
419
  /^The arguments provided to `[^`]+` are invalid JSON and could not be parsed:/i.test(leading) ||
425
420
  /^new Goal tasks must omit ids; use updates for existing tasks$/i.test(leading) ||
426
421
  /^goal task exceeds \d+ characters$/i.test(leading) ||
427
- /^goal (?:tasks|updates) support at most \d+ entries$/i.test(leading) ||
422
+ /^goal (?:tasks|updates) support at most \d+ entries(?:$|;)/i.test(leading) ||
428
423
  /^goal arguments contain unknown fields:/i.test(leading) ||
429
424
  /^Unsupported field for GitHub [\w.]+\.$/i.test(leading) ||
430
425
  /^browser action "[^"]+" (?:does not accept input field|requires input\.|accepts only one input target form|fields must be inside input:|input\.[^\n]*(?:must be|requires|accepts at most))/i.test(
@@ -21,6 +21,7 @@ import {
21
21
  } from './retry-classifier.mjs';
22
22
  import { PROVIDER_FIRST_BYTE_TIMEOUT_MS, createTimeoutSignal } from '../stall-policy.mjs';
23
23
  import { noteFastModeCapacityError } from './anthropic-fast-mode.mjs';
24
+ import { noteRequestServiceTier } from '../../../shared/llm/usage-context.mjs';
24
25
  import { notifyCurrentAnthropicRateLimit } from './admission-scheduler.mjs';
25
26
 
26
27
  /**
@@ -103,6 +104,7 @@ export function createAnthropicApiTransport({ client, label, opts, useModel, par
103
104
  const requestStreamingResponse = () =>
104
105
  withRetry(
105
106
  async ({ signal: attemptSignal }) => {
107
+ noteRequestServiceTier(params.speed);
106
108
  const res = await client.messages.create(params, requestOptions(attemptSignal)).asResponse();
107
109
  if (!res.ok) {
108
110
  const text = await res.text().catch(() => '');
@@ -156,7 +158,10 @@ export function createAnthropicApiTransport({ client, label, opts, useModel, par
156
158
 
157
159
  const requestNonStreamingMessage = (nonStreamingParams, signal) =>
158
160
  withRetry(
159
- async ({ signal: attemptSignal }) => client.messages.create(nonStreamingParams, requestOptions(attemptSignal)),
161
+ async ({ signal: attemptSignal }) => {
162
+ noteRequestServiceTier(nonStreamingParams.speed);
163
+ return client.messages.create(nonStreamingParams, requestOptions(attemptSignal));
164
+ },
160
165
  {
161
166
  ...retryPolicy(signal, `${label} Anthropic non-streaming fallback`, `${label}-nonstreaming-request`),
162
167
  onRetry: ({ attempt, maxAttempts, lastErr, delayMs }) =>
@@ -12,6 +12,7 @@ import { traceAgentFetch } from '../agent-trace.mjs';
12
12
  import { createAbortController } from '../../../shared/abort-controller.mjs';
13
13
  import { notifyCurrentAnthropicRateLimit } from './admission-scheduler.mjs';
14
14
  import { noteFastModeCapacityError } from './anthropic-fast-mode.mjs';
15
+ import { noteRequestServiceTier } from '../../../shared/llm/usage-context.mjs';
15
16
  import {
16
17
  ANTHROPIC_RETRY_BACKOFF_MS,
17
18
  ANTHROPIC_RETRY_JITTER_RATIO,
@@ -66,6 +67,7 @@ export function createAnthropicOAuthRequest({
66
67
  // provider-visible cache breakpoint off the cached one — the
67
68
  // exact COLD-turn bug this change fixes. Order is fixed:
68
69
  // build → sanitize (once) → mark → prepare image bytes → JSON.stringify.
70
+ noteRequestServiceTier(requestBody?.speed);
69
71
  const response = await postMessages({
70
72
  accessToken,
71
73
  requestBody,
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { getAgentApiKey } from '../../../shared/config.mjs';
3
+ import { noteRequestServiceTier } from '../../../shared/llm/usage-context.mjs';
3
4
  import {
4
5
  knownToolNamesFromOpenAITools,
5
6
  parseToolCalls,
@@ -510,6 +511,15 @@ function selectParameterizedCursorVariant(group, requested, sendOpts = {}) {
510
511
  };
511
512
  }
512
513
 
514
+ // Fast is a RequestedModel parameter on parameterized models and an id
515
+ // suffix on raw variants.
516
+ function cursorSelectionIsFast(selection) {
517
+ if (selection.parameters?.length) {
518
+ return selection.parameters.some((parameter) => parameter.id === 'fast' && parameter.value === 'true');
519
+ }
520
+ return parseCursorVariantId(selection.modelId).fast;
521
+ }
522
+
513
523
  function selectCursorVariant(group, { effort = null, fast = false } = {}) {
514
524
  if (!Array.isArray(group) || group.length === 0) return null;
515
525
  const normalizedEffort =
@@ -680,11 +690,13 @@ class CursorProviderBase {
680
690
  sendOpts.sessionId || sendOpts.providerCacheKey || sendOpts.promptCacheKey || `cursor-call:${randomUUID()}`
681
691
  );
682
692
  const openAiTools = tools?.length ? toOpenAITools(tools) : undefined;
683
- const dispatch = () =>
684
- this._dispatchChat(
693
+ const dispatch = () => {
694
+ noteRequestServiceTier(cursorSelectionIsFast(cursorSelection) ? 'fast' : '');
695
+ return this._dispatchChat(
685
696
  this._chatBody(messages, { cursorSelection, sessionScope, openAiTools, toolChoice: sendOpts.toolChoice }),
686
697
  { runtime, accessToken, signal, sendOpts, openAiTools }
687
698
  );
699
+ };
688
700
  let assembled;
689
701
  try {
690
702
  assembled = await dispatch();
@@ -702,9 +714,15 @@ class CursorProviderBase {
702
714
  assembled = await dispatch();
703
715
  }
704
716
  const rawUsage = assembled.rawUsage;
717
+ // A pinned raw variant (effort/Fast suffix) bills as its catalog model;
718
+ // the Fast part is carried by the request's tier, not the id.
719
+ const pinned = parseCursorVariantId(canonicalCursorModelId(String(model || '').trim()));
720
+ const pricingModel =
721
+ pinned.baseId !== pinned.id && this._cursorCatalog?.groups?.has(pinned.baseId) ? pinned.baseId : undefined;
705
722
  return {
706
723
  content: assembled.content || '',
707
724
  model: model || assembled.model || 'auto',
725
+ ...(pricingModel ? { pricingModel } : {}),
708
726
  toolCalls: assembled.toolCalls,
709
727
  stopReason: assembled.stopReason,
710
728
  ...(assembled.reasoningContent ? { reasoningContent: assembled.reasoningContent } : {}),
@@ -39,6 +39,7 @@ import {
39
39
  resolveProviderCacheKey,
40
40
  } from '../agent-runtime/cache-strategy.mjs';
41
41
  import { preconnect } from '../../../shared/llm/http-agent.mjs';
42
+ import { noteRequestServiceTier } from '../../../shared/llm/usage-context.mjs';
42
43
  import { sendViaHttpSse, _envFlag } from './openai-oauth-http-sse.mjs';
43
44
  import { warmCodexClientVersion } from './codex-client-meta.mjs';
44
45
  import { CODEX_BACKEND_ORIGIN } from './openai-codex-endpoints.mjs';
@@ -209,6 +210,8 @@ export class OpenAIOAuthProvider {
209
210
  const authState = { tokens: await _authP };
210
211
  await _verP;
211
212
  const body = await _bodyP;
213
+ // Every WS/HTTP attempt of this send carries this body's tier unchanged.
214
+ noteRequestServiceTier(body.service_tier);
212
215
  // poolKey != cacheKey by design. poolKey isolates socket/delta state per
213
216
  // session. cacheKey is body.prompt_cache_key and affects prompt-cache
214
217
  // routing only; Codex handshake identity comes from sendOpts and remains
@@ -23,6 +23,7 @@ import { applyOpenAIDirectCachePolicy, openAiDirectSupportsFast } from './openai
23
23
  import { streamCallbacks } from './lib/send-callbacks.mjs';
24
24
  import { getAgentApiKey } from '../../../shared/provider-api-key.mjs';
25
25
  import { getLlmDispatcher } from '../../../shared/llm/http-agent.mjs';
26
+ import { noteRequestServiceTier } from '../../../shared/llm/usage-context.mjs';
26
27
  import { resolveProviderCacheKey, resolveProviderPromptCacheLane } from '../agent-runtime/cache-strategy.mjs';
27
28
 
28
29
  function applyOpenAIDirectFastTier(body, model, opts) {
@@ -122,6 +123,7 @@ export class OpenAIDirectProvider {
122
123
  // so a model can opt into Fast even when the OAuth catalog does not
123
124
  // advertise a Fast tier for its OAuth endpoint.
124
125
  applyOpenAIDirectFastTier(body, useModel, opts);
126
+ noteRequestServiceTier(body.service_tier);
125
127
  // Keep public response storage and model-specific cache options out of
126
128
  // the shared OAuth payload. Storage opt-out still forces full frames in
127
129
  // _computeDelta; retained output items make those frames self-contained.
@@ -34,7 +34,18 @@ export function optionalGoalTaskChanges({ tasks, updates } = {}) {
34
34
  export function normalizeGoalTasks(input, previous = [], { strict = false } = {}) {
35
35
  if (input == null) return previous.map((task) => ({ ...task }));
36
36
  if (!Array.isArray(input)) throw new Error('goal tasks must be an array');
37
- if (input.length > MAX_GOAL_TASKS) throw new Error(`goal tasks support at most ${MAX_GOAL_TASKS} entries`);
37
+ if (input.length > MAX_GOAL_TASKS) {
38
+ // Settled rows count toward the cap; set_tasks may omit them (only
39
+ // unfinished tasks must be retained), which is how a long Goal makes room.
40
+ const settled = previous.some((task) => GOAL_TASK_SETTLED.includes(task?.status));
41
+ throw new Error(
42
+ `goal tasks support at most ${MAX_GOAL_TASKS} entries${
43
+ settled
44
+ ? '; completed and dropped tasks count, so to add more send set_tasks with only the unfinished tasks plus the new ones'
45
+ : ''
46
+ }`
47
+ );
48
+ }
38
49
  const previousByText = new Map(previous.map((task) => [clean(task?.text), task]));
39
50
  const reservedIds = new Set([...previous, ...input].map((task) => clean(task?.id)).filter(Boolean));
40
51
  let nextId = 1;
@@ -61,7 +61,7 @@ const ZERO_MATCH_PREFIXES = [
61
61
  const READ_ONLY_NAVIGATION_TOOLS = new Set(['find', 'glob', 'grep', 'list', 'read', 'code_graph']);
62
62
 
63
63
  const NAVIGATION_MISS_RE =
64
- /\b(?:enoent|enotdir)\b|path does not exist|directory does not exist|no such (?:file|path)|not found at this path|file not found in graph/i;
64
+ /\b(?:enoent|enotdir)\b|path does not exist|directory does not exist|no such (?:file|path)|not found at this path|file not found\b|symbol "[^"\n]*" not found in /i;
65
65
 
66
66
  export function isReadOnlyNavigationMiss(toolName, result) {
67
67
  if (!READ_ONLY_NAVIGATION_TOOLS.has(String(toolName || '').toLowerCase())) return false;
@@ -3,7 +3,11 @@
3
3
  * session runtime.
4
4
  */
5
5
  import { stripShellExitHeader, toolErrorDisplay } from './tool-result-text.mjs';
6
- import { normalizeToolTerminalStatus, toolResultTerminalStatus } from '../../../../shared/tool-status.mjs';
6
+ import {
7
+ hasBenignExitOutcome,
8
+ normalizeToolTerminalStatus,
9
+ toolResultTerminalStatus,
10
+ } from '../../../../shared/tool-status.mjs';
7
11
  import { isReadOnlyNavigationMiss } from '../result-classification.mjs';
8
12
  import { formatAggregateDetail, summarizeToolResult, toolLoadingTargets } from '../../../../shared/tool-surface.mjs';
9
13
  import { normalizeToolName } from '../../../../shared/tool-primitives.mjs';
@@ -43,7 +47,7 @@ export function shellCommandExitCode(text) {
43
47
  export function toolCallOutcome(message, rawText) {
44
48
  if (normalizeToolName(message?.toolName || message?.name) === 'git') {
45
49
  const exitCode = gitResultExitCode(rawText);
46
- if (exitCode !== null) return { isCallError: false, isExitError: true, exitCode };
50
+ if (exitCode !== null) return { isCallError: false, isExitError: !hasBenignExitOutcome(rawText), exitCode };
47
51
  if (gitResultError(rawText)) return { isCallError: true, isExitError: false, exitCode: null };
48
52
  }
49
53
  const exitCode = shellCommandExitCode(rawText);
@@ -51,7 +55,7 @@ export function toolCallOutcome(message, rawText) {
51
55
  // Every completed shell result carries `[exit code: N]` — including 0.
52
56
  // A non-zero code is a command failure unless execution marked a known
53
57
  // successful no-match/no-change outcome.
54
- const benignExit = exitCode !== 0 && /^\[outcome:\s*(?:no-match|no-change)\]\s*$/im.test(String(rawText || ''));
58
+ const benignExit = exitCode !== 0 && hasBenignExitOutcome(rawText);
55
59
  return { isCallError: false, isExitError: exitCode !== 0 && !benignExit, exitCode };
56
60
  }
57
61
  if (isReadOnlyNavigationMiss(message?.toolName || message?.name, rawText)) {
@@ -14,6 +14,8 @@ import {
14
14
  // shared shell tokenizers, so quoted/commented `;` `|` `grep` can never
15
15
  // masquerade as a connector/command and hide a real failure.
16
16
  const _SEARCH_HEADS = new Set(['select-string', 'sls', 'grep', 'egrep', 'fgrep', 'findstr']);
17
+ // `git diff` flags whose exit 1 reports a difference, not a failure.
18
+ export const GIT_DIFF_EXIT_SIGNAL_FLAGS = new Set(['--exit-code', '--quiet', '--check']);
17
19
  const _GIT_GLOBAL_VALUE_OPTS = new Set([
18
20
  '-c',
19
21
  '-C',
@@ -75,12 +77,14 @@ export function _isBenignSearchExitOne(command, exitCode, signal, stderr) {
75
77
  const head = _normalizeHead(tokens[0]);
76
78
  if (_SEARCH_HEADS.has(head)) return true;
77
79
  if (head !== 'git') return false;
78
- // `git [global-opts] diff ...` only — exact `diff` subcommand, never
79
- // diff-index/diff-files/difftool — with exit-code semantics.
80
+ // `git [global-opts] grep ...` (no match), or `git [global-opts] diff ...`
81
+ // only — exact `diff` subcommand, never diff-index/diff-files/difftool —
82
+ // with exit-code semantics.
80
83
  let i = 1;
81
84
  while (i < tokens.length && tokens[i].startsWith('-')) {
82
85
  i += _GIT_GLOBAL_VALUE_OPTS.has(tokens[i]) && !tokens[i].includes('=') ? 2 : 1;
83
86
  }
87
+ if (tokens[i] === 'grep') return true;
84
88
  if (tokens[i] !== 'diff') return false;
85
- return tokens.slice(i + 1).some((t) => t === '--exit-code' || t === '--quiet' || t === '--check');
89
+ return tokens.slice(i + 1).some((t) => GIT_DIFF_EXIT_SIGNAL_FLAGS.has(t));
86
90
  }
@@ -58,6 +58,7 @@ export const BUILTIN_TOOLS = [
58
58
  {
59
59
  type: 'array',
60
60
  minItems: 1,
61
+ maxItems: PUBLIC_PATH_BATCH_LIMIT,
61
62
  items: {
62
63
  anyOf: [
63
64
  { type: 'string' },
@@ -3,6 +3,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { basename, dirname, join, resolve } from 'node:path';
5
5
  import { tokenizeDirectArgv } from './shell-direct-exe.mjs';
6
+ import { GIT_DIFF_EXIT_SIGNAL_FLAGS } from './bash-tool/benign-exit.mjs';
6
7
  import { withBuiltinPathLocks } from './path-locks.mjs';
7
8
  import { withAdvisoryLocks } from './advisory-lock.mjs';
8
9
  import { withGitRepoReadLock, withGitRepoWriteLock } from './git-repo-rw-lock.mjs';
@@ -154,8 +155,13 @@ function commandResult(plan, result, limit) {
154
155
  const output = capOutput(appendText(String(result.stdout || ''), String(result.stderr || '')), limit);
155
156
  if (succeeded(result)) return { text: output, failed: false };
156
157
  const processError = result.error || result.timedOut || result.aborted || result.overflow || result.exitCode == null;
158
+ // An exit signal is still a non-zero exit, so a command array stops on it;
159
+ // the outcome line only keeps the card from reporting a failure.
160
+ const exitHeader = exitSignal(plan, result)
161
+ ? `exit ${result.exitCode}\n[outcome: no-match]`
162
+ : `exit ${result.exitCode}`;
157
163
  return {
158
- text: appendText(processError ? fail(gitFailureReason(plan, result)) : `exit ${result.exitCode}`, output),
164
+ text: appendText(processError ? fail(gitFailureReason(plan, result)) : exitHeader, output),
159
165
  failed: true,
160
166
  };
161
167
  }
@@ -337,6 +343,18 @@ function succeeded(result) {
337
343
  return result?.exitCode === 0 && !result.error && !result.timedOut && !result.aborted && !result.overflow;
338
344
  }
339
345
 
346
+ // `git grep` exits 1 for no match and `git diff --exit-code/--quiet/--check`
347
+ // exits 1 to report a difference; the shell tool marks the same exits as
348
+ // signals, so the git tool does too.
349
+ function exitSignal(plan, result) {
350
+ if (result.exitCode !== 1 || String(result.stderr || '').trim()) return false;
351
+ if (plan.operation === 'grep') return true;
352
+ if (plan.operation !== 'diff') return false;
353
+ const separator = plan.args.indexOf('--');
354
+ const options = separator === -1 ? plan.args : plan.args.slice(0, separator);
355
+ return options.some((arg) => GIT_DIFF_EXIT_SIGNAL_FLAGS.has(arg));
356
+ }
357
+
340
358
  // ENOENT from the spawn itself is a capability fact, not a git error: the
341
359
  // executable is absent. Naming it stops the caller from re-running git a
342
360
  // different way to find out.
@@ -780,8 +798,26 @@ function splitChainedGitCommands(command) {
780
798
  return commands.length > 1 ? commands : null;
781
799
  }
782
800
 
783
- export async function executeGitTool(input, workDir, options = {}) {
784
- if (!input || typeof input !== 'object' || Array.isArray(input)) return fail('git requires an arguments object');
801
+ // The tool only runs git, so a submitted command that omits the leading `git`
802
+ // ("status --short") gets it instead of a rejection. Only whole submitted
803
+ // commands are prefixed; a `&&` piece after the first still has to be a full
804
+ // git command, so non-git chain segments stay refused.
805
+ function withGitPrefix(command) {
806
+ if (typeof command !== 'string') return command;
807
+ const text = String(unwrapQuotedCommand(command)).trim();
808
+ const first = text ? tokenizeDirectArgv(text)?.[0] : '';
809
+ if (!first || /(^|[\\/])git(?:\.exe)?$/i.test(first)) return command;
810
+ return `git ${text}`;
811
+ }
812
+
813
+ export async function executeGitTool(rawInput, workDir, options = {}) {
814
+ if (!rawInput || typeof rawInput !== 'object' || Array.isArray(rawInput))
815
+ return fail('git requires an arguments object');
816
+ const input = {
817
+ ...rawInput,
818
+ command: Array.isArray(rawInput.command) ? rawInput.command.map(withGitPrefix) : withGitPrefix(rawInput.command),
819
+ };
820
+ if (rawInput.command === undefined) delete input.command;
785
821
  const action = input.action ?? 'command';
786
822
  if (action === 'stage') {
787
823
  if (input.command !== undefined || input.include_stage_ids !== undefined) {
@@ -7,9 +7,12 @@ import { __mixdogMemoryLog } from './memory-log.mjs';
7
7
  // Downloads and verifies a prebuilt native PG runtime from the mixdog GitHub
8
8
  // release manifest.
9
9
  //
10
- // Layout: <dataDir>/runtime/runtime-{ver}/ + <dataDir>/runtime/active-version
10
+ // Layout: <dataDir>/runtime/runtime-{key}/ + <dataDir>/runtime/active-version
11
+ // holding {key}. New installs key the directory by version plus asset digest;
12
+ // installs from before that keep their bare-version key and stay valid.
11
13
  // Atomic swap: write active-version.tmp then rename → active-version.
12
- // GC: removes stale runtime-* dirs and staging-* dirs on every ensureRuntime call.
14
+ // GC: removes stale runtime-* dirs and staging-* dirs on every ensureRuntime
15
+ // call, but only a dir that can be moved aside whole (never one in use).
13
16
  //
14
17
  // Public API: ensureRuntime(dataDir) → { runtimeDir, pgBinDir, libDir, sharePath, version }
15
18
 
@@ -21,6 +24,7 @@ import {
21
24
  openSync,
22
25
  readFileSync,
23
26
  readdirSync,
27
+ renameSync,
24
28
  rmSync,
25
29
  statSync,
26
30
  unlinkSync,
@@ -109,6 +113,38 @@ function runtimeVerDir(runtimeDir, ver) {
109
113
  return join(runtimeDir, `runtime-${ver}`);
110
114
  }
111
115
 
116
+ // A rebuilt asset of the same version gets its own directory, so installing it
117
+ // never touches the tree a still-running PostgreSQL may hold open.
118
+ function runtimeInstallKey(version, sha256) {
119
+ return `${version}-${String(sha256).slice(0, 12)}`;
120
+ }
121
+
122
+ // True while a PostgreSQL from this runtime is running: a running executable
123
+ // cannot be opened for writing (EBUSY on Windows, ETXTBSY on Linux).
124
+ function runtimeDirInUse(dir) {
125
+ const exe = join(dir, 'bin', process.platform === 'win32' ? 'postgres.exe' : 'postgres');
126
+ try {
127
+ closeSync(openSync(exe, 'r+'));
128
+ return false;
129
+ } catch (error) {
130
+ return error?.code === 'EBUSY' || error?.code === 'ETXTBSY';
131
+ }
132
+ }
133
+
134
+ // Remove a runtime dir only when nothing runs from it, moving it aside whole
135
+ // first. Deleting in place could strip a running PostgreSQL's files and the
136
+ // sha stamp while the locked binaries survive, leaving a tree that looks
137
+ // installed but is not; an in-use dir makes this throw and stays intact.
138
+ function discardRuntimeDir(runtimeDir, dir) {
139
+ if (runtimeDirInUse(dir)) throw new Error(`[runtime-fetcher] runtime dir is in use: ${dir}`);
140
+ const trash = join(
141
+ runtimeDir,
142
+ `staging-discard-${Date.now()}-${process.pid}-${Math.random().toString(36).slice(2, 8)}`
143
+ );
144
+ renameSync(dir, trash);
145
+ rmSync(trash, { recursive: true, force: true });
146
+ }
147
+
112
148
  function runtimePaths(verDir) {
113
149
  return {
114
150
  pgBinDir: join(verDir, 'bin'),
@@ -370,9 +406,11 @@ function gcRuntimeDir(runtimeDir, keepVer) {
370
406
  continue;
371
407
  }
372
408
  const dir = join(runtimeDir, name);
373
- const lockPath = stagingLockPath(dir);
374
- // Never wipe a staging dir guarded by a LIVE lock — that is a sibling
375
- // process mid-extract/swap.
409
+ // A download (staging-{tag}.tar.gz) is guarded by its staging dir's lock.
410
+ const guardName = name.endsWith('.tar.gz') ? name.slice(0, -'.tar.gz'.length) : name;
411
+ const lockPath = stagingLockPath(join(runtimeDir, guardName));
412
+ // Never wipe a staging dir or download guarded by a LIVE lock — that is
413
+ // a sibling process mid-download/extract/swap.
376
414
  if (_stagingLockIsLive(lockPath)) continue;
377
415
  try {
378
416
  rmSync(dir, { recursive: true, force: true });
@@ -382,8 +420,10 @@ function gcRuntimeDir(runtimeDir, keepVer) {
382
420
  if (existsSync(lockPath)) unlinkSync(lockPath);
383
421
  } catch {}
384
422
  } else if (name.startsWith('runtime-') && name !== `runtime-${keepVer}`) {
423
+ const path = join(runtimeDir, name);
385
424
  try {
386
- rmSync(join(runtimeDir, name), { recursive: true, force: true });
425
+ if (statSync(path).isDirectory()) discardRuntimeDir(runtimeDir, path);
426
+ else rmSync(path, { force: true });
387
427
  } catch {}
388
428
  }
389
429
  }
@@ -451,11 +491,36 @@ function selectRuntimeAsset(manifest, pkey) {
451
491
  /** Fast path: the active-version pointer names this version and the extracted
452
492
  * tree carries the expected sha256. Returns the reusable dir, else null. */
453
493
  function cachedRuntimeDir(runtimeBaseDir, version, sha256) {
454
- if (readActiveVersion(runtimeBaseDir) !== version) return null;
455
- const verDir = runtimeVerDir(runtimeBaseDir, version);
456
- if (!existsSync(join(verDir, '.version-sha256'))) return null;
457
- const stored = readFileSync(join(verDir, '.version-sha256'), 'utf8').trim();
458
- return stored === sha256 ? verDir : null;
494
+ const key = readActiveVersion(runtimeBaseDir);
495
+ if (key !== version && key !== runtimeInstallKey(version, sha256)) return null;
496
+ const verDir = runtimeVerDir(runtimeBaseDir, key);
497
+ return readRuntimeStamp(verDir) === sha256 ? verDir : null;
498
+ }
499
+
500
+ // The asset digest stamped into a complete runtime tree, '' when absent.
501
+ function readRuntimeStamp(verDir) {
502
+ try {
503
+ return readFileSync(join(verDir, '.version-sha256'), 'utf8').trim();
504
+ } catch {
505
+ return '';
506
+ }
507
+ }
508
+
509
+ // Publish an extracted, stamped staging tree as runtime-{key} and point
510
+ // active-version at it. A sibling that raced this install may already have
511
+ // published the same asset: that tree is kept (it may be running) and this
512
+ // copy dropped. Any other runtime-{key} tree is an interrupted install of this
513
+ // asset; it is moved aside whole, and an in-use one fails the publish intact.
514
+ function publishRuntimeDir(runtimeBaseDir, stagingDir, key, sha256) {
515
+ const verDir = runtimeVerDir(runtimeBaseDir, key);
516
+ if (readRuntimeStamp(verDir) === sha256) {
517
+ rmSync(stagingDir, { recursive: true, force: true });
518
+ } else {
519
+ if (existsSync(verDir)) discardRuntimeDir(runtimeBaseDir, verDir);
520
+ renameWithRetrySync(stagingDir, verDir);
521
+ }
522
+ writeFileAtomicSync(activeVersionPath(runtimeBaseDir), key, { fsyncDir: true });
523
+ return verDir;
459
524
  }
460
525
 
461
526
  /**
@@ -476,10 +541,11 @@ async function installRuntimeVersion({ runtimeBaseDir, url, sha256, size, versio
476
541
  // process fail to obtain the lock for its own staging dir).
477
542
  const stagingTag = `${Date.now()}-${process.pid}-${Math.random().toString(36).slice(2, 8)}`;
478
543
  const stagingDir = join(runtimeBaseDir, `staging-${stagingTag}`);
479
- const tarPath = join(runtimeBaseDir, `runtime-${pkey}-${stagingTag}.tar.gz`);
544
+ // Named after the staging dir so the same live lock guards it from GC.
545
+ const tarPath = `${stagingDir}.tar.gz`;
480
546
 
481
- const verDir = runtimeVerDir(runtimeBaseDir, version);
482
- const avPath = activeVersionPath(runtimeBaseDir);
547
+ const key = runtimeInstallKey(version, sha256);
548
+ const verDir = runtimeVerDir(runtimeBaseDir, key);
483
549
 
484
550
  // Cross-process lock: hold the staging lockfile (O_EXCL) for the WHOLE
485
551
  // download → extract → swap lifetime. GC in any concurrently-booting sibling
@@ -527,16 +593,11 @@ async function installRuntimeVersion({ runtimeBaseDir, url, sha256, size, versio
527
593
  normalizeBinExecBit(stagingDir);
528
594
 
529
595
  // Atomic swap:
530
- // 1. Rename staging → runtime-{ver}
596
+ // 1. Rename staging → runtime-{key} (or reuse a sibling's identical publish)
531
597
  // 2. Write active-version.tmp → rename to active-version
532
598
  // Stale dirs cleaned up by GC after.
533
599
  try {
534
- // If a prior runtime-{ver} dir exists (interrupted earlier run), remove it.
535
- if (existsSync(verDir)) {
536
- rmSync(verDir, { recursive: true, force: true });
537
- }
538
- renameWithRetrySync(stagingDir, verDir);
539
- writeFileAtomicSync(avPath, version, { fsyncDir: true });
600
+ publishRuntimeDir(runtimeBaseDir, stagingDir, key, sha256);
540
601
  } catch (swapErr) {
541
602
  __mixdogMemoryLog(`[runtime-fetcher] atomic swap failed: ${swapErr.message}\n`);
542
603
  // Attempt to leave things in a recoverable state: if verDir landed but
@@ -544,10 +605,10 @@ async function installRuntimeVersion({ runtimeBaseDir, url, sha256, size, versio
544
605
  throw swapErr;
545
606
  }
546
607
 
547
- // GC: remove stale runtime-* dirs (anything that isn't runtime-{version}).
608
+ // GC: remove stale runtime-* dirs (anything that isn't runtime-{key}).
548
609
  // Still under the lock so the just-swapped staging lockfile reap below sees
549
610
  // a consistent view.
550
- gcRuntimeDir(runtimeBaseDir, version);
611
+ gcRuntimeDir(runtimeBaseDir, key);
551
612
  } finally {
552
613
  releaseStagingLock(stagingLock);
553
614
  }
@@ -556,6 +617,16 @@ async function installRuntimeVersion({ runtimeBaseDir, url, sha256, size, versio
556
617
  return verDir;
557
618
  }
558
619
 
620
+ export const _runtimeLayoutInternals = {
621
+ acquireStagingLock,
622
+ cachedRuntimeDir,
623
+ discardRuntimeDir,
624
+ gcRuntimeDir,
625
+ publishRuntimeDir,
626
+ releaseStagingLock,
627
+ runtimeInstallKey,
628
+ };
629
+
559
630
  export async function ensureRuntime(dataDir) {
560
631
  const key = resolve(dataDir);
561
632
  if (runtimeCache.has(key)) return runtimeCache.get(key);