mixdog 0.9.175 → 1.0.1

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 (70) 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/computer-use/SKILL.md +13 -12
  5. package/src/defaults/skills/setup/SKILL.md +1 -4
  6. package/src/defaults/skills/setup/references/actions.md +0 -2
  7. package/src/defaults/skills/setup/references/surfaces.md +2 -3
  8. package/src/defaults/skills/xlsx/SKILL.md +1 -0
  9. package/src/rules/shared/70-delivery.md +3 -0
  10. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +12 -12
  11. package/src/runtime/agent/orchestrator/config.mjs +8 -1
  12. package/src/runtime/agent/orchestrator/providers/anthropic-api-transport.mjs +6 -1
  13. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-request.mjs +2 -0
  14. package/src/runtime/agent/orchestrator/providers/cursor.mjs +20 -2
  15. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +3 -0
  16. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +2 -0
  17. package/src/runtime/agent/orchestrator/providers/registry-errors.mjs +9 -0
  18. package/src/runtime/agent/orchestrator/providers/registry.mjs +3 -2
  19. package/src/runtime/agent/orchestrator/runtime-core/goal-tasks.mjs +25 -6
  20. package/src/runtime/agent/orchestrator/runtime-core/goal-text.mjs +3 -2
  21. package/src/runtime/agent/orchestrator/runtime-core/goal-tool-defs.mjs +1 -1
  22. package/src/runtime/agent/orchestrator/runtime-core/goal-tool-exec.mjs +1 -0
  23. package/src/runtime/agent/orchestrator/session/result-classification.mjs +1 -1
  24. package/src/runtime/agent/orchestrator/session/transcript-restore/tool-result-status.mjs +7 -3
  25. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +27 -4
  26. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/benign-exit.mjs +7 -3
  27. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +1 -0
  28. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +39 -3
  29. package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +3 -1
  30. package/src/runtime/agent/orchestrator/tools/builtin/read-tool/batch-dispatch.mjs +2 -1
  31. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +13 -5
  32. package/src/runtime/computer-bridge/error-recovery.mjs +1 -1
  33. package/src/runtime/memory/lib/runtime-fetcher.mjs +94 -23
  34. package/src/runtime/office/capabilities-catalog.mjs +2 -0
  35. package/src/runtime/office/capabilities-signatures.mjs +12 -6
  36. package/src/runtime/office/capabilities.mjs +30 -10
  37. package/src/runtime/office/core/office-actions-open.mjs +1 -6
  38. package/src/runtime/office/core/office-sessions.mjs +9 -0
  39. package/src/runtime/office/portable/portable-xlsx-operations.mjs +2 -49
  40. package/src/runtime/office/portable/xlsx-contract.mjs +37 -3
  41. package/src/runtime/office/tool-defs.mjs +2 -2
  42. package/src/runtime/shared/config-patch.mjs +2 -0
  43. package/src/runtime/shared/llm/cost.mjs +16 -4
  44. package/src/runtime/shared/llm/model-catalog-projection.mjs +3 -2
  45. package/src/runtime/shared/llm/model-catalog.mjs +48 -0
  46. package/src/runtime/shared/llm/model-pricing-rates.mjs +11 -4
  47. package/src/runtime/shared/llm/usage-accounting.mjs +12 -11
  48. package/src/runtime/shared/llm/usage-context.mjs +7 -0
  49. package/src/runtime/shared/llm/usage-ledger-repair.mjs +3 -1
  50. package/src/runtime/shared/llm/usage-ledger.mjs +1 -1
  51. package/src/runtime/shared/tool-card-model/card-status.mjs +7 -5
  52. package/src/runtime/shared/tool-card-model/git-result.mjs +10 -0
  53. package/src/runtime/shared/tool-card-model/terminal-status.mjs +6 -2
  54. package/src/runtime/shared/tool-card-model.golden-cases.mjs +2 -1
  55. package/src/runtime/shared/tool-result-summary.mjs +1 -0
  56. package/src/runtime/shared/tool-status.mjs +35 -2
  57. package/src/runtime/shared/user-data-guard.mjs +1 -1
  58. package/src/runtime/tidy/report.mjs +2 -2
  59. package/src/runtime/tidy/tool.mjs +6 -1
  60. package/src/session-runtime/config-lifecycle.mjs +11 -1
  61. package/src/session-runtime/cwd-plugins/core-memory-context.mjs +1 -1
  62. package/src/session-runtime/native-web-search/tool-shape.mjs +16 -4
  63. package/src/session-runtime/provider-models/catalog-load.mjs +18 -3
  64. package/src/session-runtime/setup-tool/executor.mjs +0 -1
  65. package/src/session-runtime/setup-tool/settings-contract.mjs +2 -13
  66. package/src/tui/dist/index.mjs +89 -38
  67. package/src/tui/session/session-api/settings.mjs +16 -10
  68. package/src/vendor/statusline/src/gateway/route-meta.mjs +7 -2
  69. package/src/workflows/default/WORKFLOW.md +0 -2
  70. package/src/workflows/headless/WORKFLOW.md +0 -2
@@ -1,7 +1,7 @@
1
1
  // Worksheet-level operations the portable XLSX backend applies: sheet
2
2
  // management, images, header/footer, conditional formats, validation, sort,
3
3
  // autofit, pivot tables and charts.
4
- import { conditionalFormatKind, listValidationChoices, listValidationFormula } from './xlsx-contract.mjs';
4
+ import { conditionalFormatKind, listValidationChoices, normalizeXlsxValidation } from './xlsx-contract.mjs';
5
5
  import { nextRelationshipId, partRelationshipPath, zipText } from './portable-opc.mjs';
6
6
  import {
7
7
  OFFICE_RELATIONSHIP_BASE,
@@ -45,29 +45,6 @@ function headerFooterFields(text) {
45
45
  .replace(/\{file\}/gi, '&F');
46
46
  }
47
47
 
48
- // Cell validation as both backends express it: the OOXML names here, the Excel
49
- // enumeration in the COM host.
50
- const XLSX_VALIDATION_TYPES = Object.freeze({
51
- list: 'list',
52
- whole: 'whole',
53
- decimal: 'decimal',
54
- date: 'date',
55
- time: 'time',
56
- textlength: 'textLength',
57
- custom: 'custom',
58
- });
59
-
60
- const XLSX_VALIDATION_OPERATORS = Object.freeze({
61
- between: 'between',
62
- notbetween: 'notBetween',
63
- equal: 'equal',
64
- notequal: 'notEqual',
65
- greaterthan: 'greaterThan',
66
- lessthan: 'lessThan',
67
- greaterthanorequal: 'greaterThanOrEqual',
68
- lessthanorequal: 'lessThanOrEqual',
69
- });
70
-
71
48
  export const TABLE_CONTENT_TYPE = 'application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml';
72
49
 
73
50
  // A built-in table style bands the range from the workbook's own theme, which
@@ -330,18 +307,6 @@ async function expressionConditionalRule(zip, op, priority) {
330
307
 
331
308
  // The comparison a bounded validation applies; a second bound without one
332
309
  // means between.
333
- function validationOperator(op, type) {
334
- const requested = String(op.operator || '').trim();
335
- let operator = '';
336
- if (requested && Object.hasOwn(XLSX_VALIDATION_OPERATORS, requested.toLowerCase())) {
337
- operator = XLSX_VALIDATION_OPERATORS[requested.toLowerCase()];
338
- } else if (op.formula2 != null && !['list', 'custom'].includes(type)) operator = 'between';
339
- if (requested && !operator) {
340
- throw new Error(`add_validation operator must be one of ${Object.keys(XLSX_VALIDATION_OPERATORS).join(', ')}`);
341
- }
342
- return operator;
343
- }
344
-
345
310
  function dataValidationXml(op, { type, operator, reference }) {
346
311
  const formula = (value) => `${xmlEncode(String(value).replace(/^=/, ''))}`;
347
312
  return (
@@ -359,19 +324,7 @@ function dataValidationXml(op, { type, operator, reference }) {
359
324
  /** One data validation over a range, appended to the validations already there. */
360
325
  export function addWorksheetValidation(zip, sheet, xml, op) {
361
326
  const reference = areaReference(parseAreaRange(op.range));
362
- // A list is the common case and what Excel writes through the same
363
- // operation, so it is the default; the other kinds guard a number, a
364
- // date, or a length, and take a second bound. A formula that states a
365
- // rule rather than naming choices is that rule, not a dropdown of one
366
- // entry: "서울,부산" and $A$1:$A$9 are lists, B2>0 is a custom check.
367
- const kind = String(op.type || (listValidationFormula(op.formula1) ? 'list' : 'custom'))
368
- .trim()
369
- .toLowerCase();
370
- const type = Object.hasOwn(XLSX_VALIDATION_TYPES, kind) ? XLSX_VALIDATION_TYPES[kind] : '';
371
- if (!type) {
372
- throw new Error(`add_validation type must be one of ${Object.keys(XLSX_VALIDATION_TYPES).join(', ')}`);
373
- }
374
- const operator = validationOperator(op, type);
327
+ const { type, operator = '' } = normalizeXlsxValidation(op);
375
328
  const existing = worksheetSection(xml, 'dataValidations');
376
329
  const previous = existing ? containerBody(existing[0], 'dataValidations') : '';
377
330
  const count = (previous.match(/<dataValidation\b/g) || []).length + 1;
@@ -246,6 +246,42 @@ export function listValidationChoices(formula1) {
246
246
  return `"${text}"`;
247
247
  }
248
248
 
249
+ export const XLSX_VALIDATION_TYPES = Object.freeze(['list', 'whole', 'decimal', 'date', 'time', 'textLength', 'custom']);
250
+ export const XLSX_VALIDATION_OPERATORS = Object.freeze([
251
+ 'between',
252
+ 'notBetween',
253
+ 'equal',
254
+ 'notEqual',
255
+ 'greaterThan',
256
+ 'lessThan',
257
+ 'greaterThanOrEqual',
258
+ 'lessThanOrEqual',
259
+ ]);
260
+
261
+ // Resolve and validate once, before either Excel COM or the portable writer
262
+ // can interpret an unknown name as a different rule.
263
+ export function normalizeXlsxValidation(operation) {
264
+ if (!String(operation.formula1 ?? '').trim()) throw new Error('add_validation requires non-empty formula1');
265
+ const requestedType = String(operation.type ?? '').trim() || (listValidationFormula(operation.formula1) ? 'list' : 'custom');
266
+ const type = XLSX_VALIDATION_TYPES.find((value) => value.toLowerCase() === requestedType.toLowerCase());
267
+ if (!type) throw new Error(`add_validation type must be one of ${XLSX_VALIDATION_TYPES.join(', ')}`);
268
+ const requestedOperator = String(operation.operator ?? '').trim();
269
+ const bounded = !['list', 'custom'].includes(type);
270
+ const operator = requestedOperator
271
+ ? XLSX_VALIDATION_OPERATORS.find((value) => value.toLowerCase() === requestedOperator.toLowerCase())
272
+ : bounded ? 'between' : '';
273
+ if (requestedOperator && !operator) {
274
+ throw new Error(`add_validation operator must be one of ${XLSX_VALIDATION_OPERATORS.join(', ')}`);
275
+ }
276
+ if (!bounded && requestedOperator) {
277
+ throw new Error(`add_validation operator is unsupported for type "${type}"; omit operator`);
278
+ }
279
+ if (['between', 'notBetween'].includes(operator) && !String(operation.formula2 ?? '').trim()) {
280
+ throw new Error(`add_validation operator "${operator}" requires formula2; for one bound use a comparison operator`);
281
+ }
282
+ return { type, ...(operator ? { operator } : {}) };
283
+ }
284
+
249
285
  // Three ways a sheet marks its numbers: a rule that paints the cells it picks,
250
286
  // a scale that colors every cell by where its value sits, and a bar drawn in
251
287
  // the cell. The first needs a formula and a format; the other two carry their
@@ -394,9 +430,7 @@ function validateXlsxOperation(operation) {
394
430
  // Both backends write the kind named here, so the default is settled once:
395
431
  // a formula that names choices is a dropdown, a formula that states a test
396
432
  // is a custom rule. Writing B2>0 as a list would offer it as one entry.
397
- if (op === 'add_validation' && !String(operation.type ?? '').trim()) {
398
- operation.type = listValidationFormula(operation.formula1) ? 'list' : 'custom';
399
- }
433
+ if (op === 'add_validation') Object.assign(operation, normalizeXlsxValidation(operation));
400
434
  if (op === 'set_style' && operation.cell) parseXlsxCell(operation.cell);
401
435
  if (operation.range && RANGE_OPERATIONS.includes(op)) validateRangeOperation(operation, op);
402
436
  else if (op === 'set_range') throw new Error('XLSX set_range requires range');
@@ -26,7 +26,7 @@ export const TOOL_DEFS = [
26
26
  type: 'string',
27
27
  enum: OFFICE_ACTIONS,
28
28
  description:
29
- 'detect/describe discover; author: PPTX from HTML or pptxgenjs; transactions/recover, begin/diff/commit/rollback checkpoint; create/attach/open start; snapshot/get/query inspect; batch edits; issues/qa/render/validate review; save/finalize/close finish; secure encrypts/decrypts PDF.',
29
+ 'detect/describe discover; author: HTML/pptxgenjs PPTX; transactions/recover/begin/diff/commit/rollback checkpoints; create/attach/open; snapshot/get/query; batch edits; issues/qa/validate review; render exports PDF and page images; save/finalize/close; secure: PDF passwords.',
30
30
  },
31
31
  path: { type: 'string', description: 'Document path; relative paths resolve from the caller project.' },
32
32
  script: { type: 'string', description: 'author: HTML slides or a pptxgenjs script (pptx skill).' },
@@ -75,7 +75,7 @@ export const TOOL_DEFS = [
75
75
  description:
76
76
  'auto defaults to background with Office, otherwise portable. Only explicit attach (alias live) co-edits an open file; visible opens a window; background edits a copy.',
77
77
  },
78
- output: { type: 'string', description: 'Output copy or render destination; defaults beside source.' },
78
+ output: { type: 'string', description: 'Output copy; render: exported PDF path. Defaults beside source.' },
79
79
  target: { type: 'string', description: 'Stable path from snapshot/query, e.g. /body/p[2].' },
80
80
  query: { type: 'string', description: 'Case-insensitive value search; with pdf-layout, where the text sits.' },
81
81
  queryKind: {
@@ -25,6 +25,8 @@ function applyField(current, path, change) {
25
25
  const next = isPlainObject(current) ? { ...current } : {};
26
26
  const [key, ...rest] = path;
27
27
  if (change.remove && rest.length && !Object.hasOwn(next, key)) return next;
28
+ // Seed-only field: written where it is missing, never over a stored value.
29
+ if (change.ifAbsent && !rest.length && Object.hasOwn(next, key)) return next;
28
30
  if (!rest.length && change.remove) {
29
31
  delete next[key];
30
32
  } else {
@@ -64,7 +64,7 @@ export function priceUsage(args) {
64
64
  pricingProvider: meta?.pricingProvider || identity.pricingProvider,
65
65
  pricingSource: meta?.pricingSource || null,
66
66
  ...(args.inputTokensKnown === false ? { inputTokensKnown: false } : {}),
67
- ...(args.fast ? { fast: true } : {}),
67
+ ...(args.requestServiceTier ? { requestServiceTier: args.requestServiceTier } : {}),
68
68
  ...(args.serviceTier ? { serviceTier: args.serviceTier } : {}),
69
69
  ...(written1h ? { cacheWrite1hTokens: written1h } : {}),
70
70
  };
@@ -78,7 +78,19 @@ export function priceUsage(args) {
78
78
  },
79
79
  };
80
80
  const promptTokens = input + cached + written;
81
- if (args.historicalAggregate && meta.pricingTiers?.some((tier) => promptTokens > tier.aboveInputTokens)) {
81
+ // The tier the request itself carried decides its rate: subscription quota
82
+ // is charged by the tier sent, and an Anthropic fast request either runs
83
+ // fast or is rejected (its retry is a new, standard request). Only the
84
+ // OpenAI API bills a ramp-downgraded Priority request at Standard rates,
85
+ // reporting it as service_tier "default".
86
+ const sentTier = args.requestServiceTier || '';
87
+ const premium =
88
+ (sentTier === 'priority' || sentTier === 'fast') && !(args.kind === 'api' && args.serviceTier === 'default');
89
+ // Anthropic fast mode is a flat 2x on fast-capable Opus; every other
90
+ // premium request needs the model's published Fast/Priority rates.
91
+ const anthropicFast = premium && sentTier === 'fast' && supportsAnthropicFastMode(identity.pricingModel);
92
+ const rateMeta = premium && !anthropicFast ? meta.fastPricing || {} : meta;
93
+ if (args.historicalAggregate && rateMeta.pricingTiers?.some((tier) => promptTokens > tier.aboveInputTokens)) {
82
94
  // A daily sum cannot establish which individual requests crossed a
83
95
  // context boundary. Do not price the whole day as one huge prompt.
84
96
  return { input, costUsd: null, rates: { ...provenance, unpricedReason: 'request-boundaries-unavailable' } };
@@ -93,10 +105,10 @@ export function priceUsage(args) {
93
105
  if (!peak) multiplier *= meta.offPeakMultiplier;
94
106
  }
95
107
  // Fast mode bills 2x standard rates on every fast-capable Opus.
96
- if (supportsAnthropicFastMode(identity.pricingModel) && (args.fast || args.serviceTier === 'fast')) multiplier *= 2;
108
+ if (anthropicFast) multiplier *= 2;
97
109
  const keys = PRICING_RATE_KEYS;
98
110
  const tokens = [input, n(args.outputTokens), cached, written - written1h];
99
- const tierRates = ratesForPrompt(meta, promptTokens);
111
+ const tierRates = ratesForPrompt(rateMeta, promptTokens);
100
112
  const rates = {
101
113
  ...provenance,
102
114
  ...Object.fromEntries(keys.map((key) => [key, tierRates[key] == null ? null : tierRates[key] * multiplier])),
@@ -144,10 +144,11 @@ function projectLitellmRow(row) {
144
144
  const value = row[field];
145
145
  if (typeof value === 'number' && Number.isFinite(value)) out[field] = value;
146
146
  }
147
- // Preserve the published context-rate columns, not just the base price.
147
+ // Preserve the published context-rate and Priority-tier columns (read by
148
+ // litellmPricing), not just the base price.
148
149
  for (const [field, value] of Object.entries(row)) {
149
150
  if (
150
- /^(?:input_cost_per_token|output_cost_per_token|cache_read_input_token_cost|cache_creation_input_token_cost)_above_\d+k_tokens$/.test(
151
+ /^(?:input_cost_per_token|output_cost_per_token|cache_read_input_token_cost|cache_creation_input_token_cost)(?:_above_\d+k_tokens)?(?:_priority)?$/.test(
151
152
  field
152
153
  ) &&
153
154
  typeof value === 'number' &&
@@ -443,6 +443,44 @@ const PRICED_VARIANTS = Object.freeze({
443
443
  }),
444
444
  });
445
445
 
446
+ // Cursor-billed rates no catalog publishes: Cursor's first-party SKUs and Fast
447
+ // rates Cursor sells beyond the vendor's own catalog. `fast` is the rate of a
448
+ // request Cursor ran in Fast mode. Other Cursor Fast requests bill at the
449
+ // vendor's Fast rate (Anthropic fast mode, OpenAI Priority/Fast).
450
+ // Source: https://cursor.com/docs/models-and-pricing — list $/M, verified 2026-09-30.
451
+ const CURSOR_PRICING = Object.freeze({
452
+ 'composer-2.5': {
453
+ inputCostPerM: 0.5,
454
+ cacheReadCostPerM: 0.2,
455
+ outputCostPerM: 2.5,
456
+ fast: { inputCostPerM: 3, cacheReadCostPerM: 0.5, outputCostPerM: 15 },
457
+ },
458
+ 'grok-4.7': {
459
+ fast: {
460
+ inputCostPerM: 4,
461
+ cacheReadCostPerM: 1,
462
+ outputCostPerM: 12,
463
+ // Fast long context (>256k input) bills at 3x the standard rates.
464
+ pricingTiers: [{ aboveInputTokens: 256000, inputCostPerM: 6, cacheReadCostPerM: 1.5, outputCostPerM: 18 }],
465
+ },
466
+ },
467
+ 'grok-4.6': { fast: { inputCostPerM: 4, cacheReadCostPerM: 1, outputCostPerM: 12 } },
468
+ 'grok-4.5': { fast: { inputCostPerM: 4, cacheReadCostPerM: 1, outputCostPerM: 18 } },
469
+ 'claude-opus-4-7': {
470
+ fast: { inputCostPerM: 30, cacheWriteCostPerM: 37.5, cacheReadCostPerM: 3, outputCostPerM: 150 },
471
+ },
472
+ });
473
+
474
+ function listedRates(row) {
475
+ return {
476
+ ...Object.fromEntries(PRICING_RATE_KEYS.map((key) => [key, row[key] ?? null])),
477
+ pricingTiers: (row.pricingTiers || []).map((tier) => ({
478
+ aboveInputTokens: tier.aboveInputTokens,
479
+ ...Object.fromEntries(PRICING_RATE_KEYS.map((key) => [key, tier[key] ?? null])),
480
+ })),
481
+ };
482
+ }
483
+
446
484
  function scaledRates(row, factor) {
447
485
  return Object.fromEntries(PRICING_RATE_KEYS.map((key) => [key, row?.[key] == null ? null : row[key] * factor]));
448
486
  }
@@ -512,6 +550,13 @@ function lookupModelMetadata(originalId, provider, catalog, modelsDevCatalog) {
512
550
  };
513
551
  }
514
552
  }
553
+ const cursorRow = /^cursor-(?:oauth|api)$/.test(String(provider || '').toLowerCase()) ? CURSOR_PRICING[id] : null;
554
+ if (cursorRow) {
555
+ if (!PRICING_RATE_KEYS.some((key) => meta?.[key] != null)) {
556
+ meta = { ...meta, ...listedRates(cursorRow), pricingSource: 'override' };
557
+ }
558
+ if (cursorRow.fast) meta = { ...meta, fastPricing: listedRates(cursorRow.fast) };
559
+ }
515
560
  if (providerUsesEndpointScopedLimits(provider) && !providerNative && meta) {
516
561
  // OAuth/backend routes can expose smaller account/backend windows than
517
562
  // the public API SKU. External catalogs and manual overrides remain useful
@@ -546,10 +591,13 @@ function lookupModelMetadata(originalId, provider, catalog, modelsDevCatalog) {
546
591
 
547
592
  function _normalize(entry) {
548
593
  if (!entry || typeof entry !== 'object') return null;
594
+ // OpenAI's Priority processing (since renamed Fast mode) columns.
595
+ const fastPricing = litellmPricing(entry, '_priority');
549
596
  return {
550
597
  contextWindow: entry.max_input_tokens || entry.max_tokens || null,
551
598
  outputTokens: entry.max_output_tokens || null,
552
599
  ...litellmPricing(entry),
600
+ ...(PRICING_RATE_KEYS.some((key) => fastPricing[key] != null) ? { fastPricing } : {}),
553
601
  ...(entry.off_peak_multiplier ? { offPeakMultiplier: entry.off_peak_multiplier } : {}),
554
602
  supportsVision: entry.supports_vision === true,
555
603
  supportsFunctionCalling: entry.supports_function_calling === true,
@@ -20,24 +20,31 @@ function rates(source, keys, multiplier) {
20
20
  );
21
21
  }
22
22
 
23
- export function litellmPricing(entry) {
23
+ // `suffix` selects a service-tier column set (e.g. `_priority`), published as
24
+ // `<key>_priority` and `<key>_above_<N>k_tokens_priority`.
25
+ export function litellmPricing(entry, suffix = '') {
24
26
  const thresholds = new Set();
27
+ const tierKey = new RegExp(`^_above_(\\d+)k_tokens${suffix}$`);
25
28
  for (const key of Object.keys(entry || {})) {
26
29
  for (const prefix of LITELLM_KEYS) {
27
30
  if (!key.startsWith(`${prefix}_above_`)) continue;
28
- const match = key.slice(prefix.length).match(/^_above_(\d+)k_tokens$/);
31
+ const match = key.slice(prefix.length).match(tierKey);
29
32
  if (match) thresholds.add(Number(match[1]) * 1000);
30
33
  }
31
34
  }
32
35
  return {
33
- ...rates(entry, LITELLM_KEYS, 1_000_000),
36
+ ...rates(
37
+ entry,
38
+ LITELLM_KEYS.map((key) => `${key}${suffix}`),
39
+ 1_000_000
40
+ ),
34
41
  pricingTiers: [...thresholds]
35
42
  .sort((a, b) => a - b)
36
43
  .map((aboveInputTokens) => ({
37
44
  aboveInputTokens,
38
45
  ...rates(
39
46
  entry,
40
- LITELLM_KEYS.map((key) => `${key}_above_${aboveInputTokens / 1000}k_tokens`),
47
+ LITELLM_KEYS.map((key) => `${key}_above_${aboveInputTokens / 1000}k_tokens${suffix}`),
41
48
  1_000_000
42
49
  ),
43
50
  })),
@@ -23,8 +23,18 @@ export async function accountProviderSend(provider, instance, send, model, opts
23
23
  } catch (error) {
24
24
  openingError = error;
25
25
  }
26
+ const identity = {
27
+ provider,
28
+ requestedModel: model,
29
+ sessionId,
30
+ sourceType,
31
+ inputTokensInclusive,
32
+ };
26
33
  const record = async (result) => {
27
34
  if (!result?.usage) return;
35
+ // A nested send (e.g. a fallback model re-send) already stamped its own
36
+ // final attempt's tier; the outer context only saw the abandoned attempt.
37
+ result.requestServiceTier ??= identity.requestServiceTier || '';
28
38
  if (openingError) throw openingError;
29
39
  if (!ledger) return;
30
40
  const usage = result.usage;
@@ -46,7 +56,7 @@ export async function accountProviderSend(provider, instance, send, model, opts
46
56
  cacheWrite1hTokens: usage.cacheWrite1hTokens,
47
57
  costUsd: usage.costUsd,
48
58
  serviceTier: result.serviceTier || usage.raw?.service_tier,
49
- fast: opts.fast === true,
59
+ requestServiceTier: result.requestServiceTier,
50
60
  responseId: result.responseId,
51
61
  // The account this send is bound to, so quota history can tell one
52
62
  // connected subscription's records from another's.
@@ -68,16 +78,7 @@ export async function accountProviderSend(provider, instance, send, model, opts
68
78
  };
69
79
  let result;
70
80
  try {
71
- result = await withUsageContext(
72
- {
73
- provider,
74
- requestedModel: model,
75
- sessionId,
76
- sourceType,
77
- inputTokensInclusive,
78
- },
79
- send
80
- );
81
+ result = await withUsageContext(identity, send);
81
82
  } catch (error) {
82
83
  // Only provider-reported partial usage is recordable; never invent
83
84
  // tokens for a failed request or reinterpret an error as a success.
@@ -4,3 +4,10 @@ import { AsyncLocalStorage } from 'node:async_hooks';
4
4
  const context = new AsyncLocalStorage();
5
5
  export const withUsageContext = (identity, send) => context.run(identity, send);
6
6
  export const currentUsageContext = () => context.getStore();
7
+ // Each wire attempt stamps the tier it actually sent on its own send's
8
+ // context object, so the last attempt (the one that produced the usage) wins
9
+ // and concurrent sends never observe each other's tier.
10
+ export const noteRequestServiceTier = (tier) => {
11
+ const identity = context.getStore();
12
+ if (identity) identity.requestServiceTier = tier || '';
13
+ };
@@ -81,8 +81,10 @@ function* planUsageRepair(rows, { providerOverrides = {}, price = priceUsage })
81
81
  cacheReadTokens: row.cache_read,
82
82
  cacheWriteTokens: row.cache_write,
83
83
  cacheWrite1hTokens: oldRates?.cacheWrite1hTokens,
84
- fast: oldRates?.fast,
84
+ // Rows written before the sent tier was recorded carry `fast`.
85
+ requestServiceTier: oldRates?.requestServiceTier ?? (oldRates?.fast ? 'fast' : undefined),
85
86
  serviceTier: oldRates?.serviceTier,
87
+ kind,
86
88
  ts: row.ts,
87
89
  historical: true,
88
90
  });
@@ -232,7 +232,7 @@ export function makeUsageRecord(args) {
232
232
  const ts = Number(args.ts ?? Date.now());
233
233
  if (!Number.isSafeInteger(ts) || ts <= 0) throw new Error('Usage requires a valid timestamp');
234
234
  const kind = args.kind || usageRouteKind(provider);
235
- const priced = priceUsage({ ...args, provider, model, ts });
235
+ const priced = priceUsage({ ...args, provider, model, ts, kind });
236
236
  const supplied =
237
237
  args.costUsd !== null &&
238
238
  args.costUsd !== undefined &&
@@ -8,7 +8,8 @@ import { agentTerminalDetail, isAgentTool } from './agent-surface.mjs';
8
8
  import { backgroundTaskElapsed, backgroundTaskFailureDetail, isBackgroundTaskTool } from './background-task.mjs';
9
9
  import { shellDisplayStatus } from './shell-surface.mjs';
10
10
  import { normalizeTerminalStatus, resultTerminalStatus, shellResultElapsed } from './terminal-status.mjs';
11
- import { gitResultError, gitResultExitCode } from './git-result.mjs';
11
+ import { gitTerminalStatus } from './git-result.mjs';
12
+ import { isUserControlCancellation } from '../tool-status.mjs';
12
13
 
13
14
  function shellFragments(base, display, isShellSurface) {
14
15
  if (!isShellSurface) return { shellStatus: '', shellElapsed: '' };
@@ -59,16 +60,17 @@ export function deriveCardStatus(base, { normalizedName, parsedArgs, isShellSurf
59
60
  !pending && isAgentTool(normalizedName) && !agentHeaderFailure
60
61
  ? agentTerminalDetail(parsedArgs?.status, isError, elapsed, parsedArgs?.error)
61
62
  : '';
62
- const gitFailed =
63
- normalizedName === 'git' &&
64
- (gitResultExitCode(display.displayedResultText) !== null || Boolean(gitResultError(display.displayedResultText)));
65
- const failedOrCompleted = isError || failedCount > 0 || gitFailed ? 'failed' : 'completed';
63
+ const gitStatus = normalizedName === 'git' ? gitTerminalStatus(display.displayedResultText) : '';
64
+ const failedOrCompleted = isError || failedCount > 0 ? 'failed' : 'completed';
66
65
  const terminalStatus = pending
67
66
  ? 'running'
68
67
  : shellStatus ||
68
+ gitStatus ||
69
69
  normalizeTerminalStatus(display.backgroundMeta?.status) ||
70
70
  normalizeTerminalStatus(parsedArgs?.status) ||
71
71
  resultTerminalStatus(display.displayedResultText) ||
72
+ // A one-line error body is hidden from display, so read the raw result.
73
+ (isUserControlCancellation(base.rt) ? 'cancelled' : '') ||
72
74
  failedOrCompleted;
73
75
  return {
74
76
  shellStatus,
@@ -1,3 +1,5 @@
1
+ import { hasBenignExitOutcome } from '../tool-status.mjs';
2
+
1
3
  // Only command result headers, not arbitrary lines in a patch, carry exits.
2
4
  export function gitResultExitCode(text) {
3
5
  const match = /(?:^(?:## [^\n]+\n)?|\n## [^\n]+\n)exit ([1-9]\d*)\r?(?:\n|$)/.exec(String(text ?? ''));
@@ -7,3 +9,11 @@ export function gitResultExitCode(text) {
7
9
  export function gitResultError(text) {
8
10
  return /(?:^|\n)error: command failed: [^\n]+|^error: [^\n]+/.exec(String(text ?? ''))?.[0].trim() || '';
9
11
  }
12
+
13
+ // A tool error fails the call; a git process that ran and exited non-zero is
14
+ // a command exit, like the shell's, unless the tool marked the exit a signal.
15
+ export function gitTerminalStatus(text) {
16
+ if (gitResultError(text)) return 'failed';
17
+ if (gitResultExitCode(text) !== null && !hasBenignExitOutcome(text)) return 'exit';
18
+ return '';
19
+ }
@@ -2,11 +2,11 @@
2
2
  * terminal-status.mjs — terminal status words, their outcome tone, status
3
3
  * marker stripping and the ` · <time>` detail conventions.
4
4
  */
5
- import { normalizeToolTerminalStatus, toolResultTerminalStatus } from '../tool-status.mjs';
5
+ import { leadingResultBlock, normalizeToolTerminalStatus, toolResultTerminalStatus } from '../tool-status.mjs';
6
6
  import { formatElapsed } from '../time-format.mjs';
7
7
 
8
8
  export function shellResultStatus(value) {
9
- const match = String(value || '').match(
9
+ const match = leadingResultBlock(value).match(
10
10
  /(?:^|\b)status:\s*(running|pending|queued|completed|failed|cancelled|canceled)\b/im
11
11
  );
12
12
  return match ? String(match[1] || '').toLowerCase() : '';
@@ -48,6 +48,7 @@ const TERMINAL_STATUS_LABELS = new Map([
48
48
  ['cancelled', 'Cancelled'],
49
49
  ['denied', 'Denied'],
50
50
  ]);
51
+ const BARE_STATUS_WORDS = new Set([...TERMINAL_STATUS_LABELS.values()].map((label) => label.toLowerCase()));
51
52
 
52
53
  export function displayTerminalStatus(value) {
53
54
  // 'exit' is a shell-only pseudo-status (command RAN but exited non-zero); it
@@ -107,5 +108,8 @@ export function mergeTerminalDetail(status, detail = '') {
107
108
  if (label === 'Finished' && text) return text;
108
109
  if (!text) return label;
109
110
  if (text.toLowerCase().startsWith(label.toLowerCase())) return text;
111
+ // A bare status word (the generic "Failed" of an error without a body) is
112
+ // superseded by the resolved status, e.g. a user-takeover "Cancelled".
113
+ if (BARE_STATUS_WORDS.has(text.toLowerCase())) return label;
110
114
  return `${label} · ${text}`;
111
115
  }
@@ -16,7 +16,8 @@ export const GIT_GOLDEN_CASES = [
16
16
  summary: 'diff --git a/a.txt b/a.txt',
17
17
  status: 'completed',
18
18
  },
19
- { result: 'exit 128\nfatal: missing ref\n', summary: 'Exit 128', status: 'failed' },
19
+ { result: 'exit 128\nfatal: missing ref\n', summary: 'Exit 128', status: 'exit' },
20
+ { result: 'exit 1\n[outcome: no-match]\ndiff --git a/a.txt b/a.txt\n', summary: 'Exit 1', status: 'completed' },
20
21
  {
21
22
  result: '## git show missing\nexit 128\nfatal: missing\nerror: command failed: git show missing',
22
23
  summary: 'Exit 128',
@@ -312,6 +312,7 @@ function summarizeLineCount({ text, trimmed }, { zero, singular, plural }) {
312
312
  function summarizeReadResult({ text, trimmed }) {
313
313
  if (/^\[image:/i.test(trimmed)) return 'Image';
314
314
  if (!trimmed) return null;
315
+ if (trimmed.startsWith('(no lines in range')) return '0 lines';
315
316
  const n = text.split('\n').length;
316
317
  return `${n} ${pluralize(n, 'line')}`;
317
318
  }
@@ -23,15 +23,48 @@ export function normalizeToolTerminalStatus(value) {
23
23
  return '';
24
24
  }
25
25
 
26
+ // Status markers live in the result's leading block: the cancel marker, the
27
+ // shell exit header, a background-task header, a task/agent status line.
28
+ // Command output and fetched documents follow a blank line and can carry
29
+ // `status:` text of their own, which says nothing about the tool call.
30
+ export function leadingResultBlock(text) {
31
+ const lines = String(text || '').split('\n');
32
+ let index = 0;
33
+ if (/^\[tool output offloaded:/i.test(String(lines[0] || '').trim())) {
34
+ index = 1;
35
+ while (index < lines.length && !lines[index].trim()) index += 1;
36
+ }
37
+ const block = [];
38
+ for (; index < lines.length && lines[index].trim(); index += 1) block.push(lines[index]);
39
+ return block.join('\n');
40
+ }
41
+
42
+ // The user taking over the browser/desktop, or the session cancelling a
43
+ // computer command, stops the call on purpose; it is not a failed invocation.
44
+ const USER_CONTROL_CANCELLATION_RE =
45
+ /^(?:Error:\s*)?(?:Browser command interrupted by local user input\.|computer_session_aborted:|computer_user_control_active:)/i;
46
+
47
+ export function isUserControlCancellation(text) {
48
+ return USER_CONTROL_CANCELLATION_RE.test(String(text || '').trimStart());
49
+ }
50
+
51
+ // A non-zero exit the tool itself marked as a signal (search no-match, a
52
+ // `git diff --exit-code` difference) rather than a command failure.
53
+ export function hasBenignExitOutcome(text) {
54
+ return /^\[outcome:\s*(?:no-match|no-change)\]\s*$/im.test(String(text || ''));
55
+ }
56
+
26
57
  export function toolResultTerminalStatus(text) {
27
58
  const body = String(text || '');
28
59
  const tagged = body.match(/<status[^>]*>([\s\S]*?)<\/status>/i)?.[1]?.trim();
29
60
  if (tagged) return normalizeToolTerminalStatus(tagged);
30
- const bracketed = body.match(/^\[status:\s*([^\]]*)\]/im)?.[1]?.trim();
61
+ const head = leadingResultBlock(body);
62
+ const bracketed = head.match(/^\[status:\s*([^\]]*)\]/im)?.[1]?.trim();
31
63
  if (bracketed) return normalizeToolTerminalStatus(bracketed);
32
- const inline = body.match(/^(?:status|state):\s*([^\s·,;]+)/im)?.[1]?.trim();
64
+ const inline = head.match(/^(?:status|state):\s*([^\s·,;]+)/im)?.[1]?.trim();
33
65
  const fromInline = normalizeToolTerminalStatus(inline);
34
66
  if (fromInline) return fromInline;
67
+ if (isUserControlCancellation(body)) return 'cancelled';
35
68
  // Bare control bodies written on cancel/crash (current + legacy).
36
69
  const trimmed = body.trim();
37
70
  if (/^(?:cancelled|canceled)$/i.test(trimmed)) return 'cancelled';
@@ -28,7 +28,7 @@ function getBackupRoot() {
28
28
  return process.env.MIXDOG_USER_DATA_BACKUP_ROOT || join(mixdogConfigBaseDir(), 'backups', 'user-data');
29
29
  }
30
30
 
31
- const USER_DATA_FILES = ['mixdog-config.json', 'instructions.md', 'user-workflow.md'];
31
+ const USER_DATA_FILES = ['mixdog-config.json'];
32
32
 
33
33
  const USER_DATA_DIRS = ['schedules', 'webhooks', 'workflows'];
34
34
 
@@ -11,10 +11,10 @@ const FILE_LIST_CAP = 25;
11
11
  export const RESULTS_PAGE_MAX = 100;
12
12
  const TRIM_STEPS = [8, 3, 0];
13
13
 
14
- export function tidyToolResult(value, isError = false) {
14
+ export function tidyToolResult(value, isError = value?.ok === false) {
15
15
  return {
16
16
  content: [{ type: 'text', text: JSON.stringify(value) }],
17
- ...(isError || value?.ok === false ? { isError: true } : {}),
17
+ ...(isError ? { isError: true } : {}),
18
18
  };
19
19
  }
20
20
 
@@ -571,7 +571,12 @@ export async function executeTidyTool(args = {}, { cwd = process.cwd(), signal =
571
571
  return tidyToolResult(await rulesAction({ cwd, signal, startedAt }));
572
572
  }
573
573
  if (action === 'results') {
574
- return tidyToolResult(await resultsAction({ args, cwd, sessionId, engineFilter, pathFilter: scope, startedAt }));
574
+ // Paging a stored run succeeds even when that run was incomplete; the
575
+ // report's ok/status still describe the run itself.
576
+ return tidyToolResult(
577
+ await resultsAction({ args, cwd, sessionId, engineFilter, pathFilter: scope, startedAt }),
578
+ false
579
+ );
575
580
  }
576
581
  return tidyToolResult(
577
582
  await runAction({
@@ -102,10 +102,20 @@ export function createConfigLifecycle({
102
102
  const adopted = adoptConfig(nextConfig, { hasSecrets });
103
103
  const changes = cfgMod.createConfigPatch(configSaveBaseline, adopted);
104
104
  configSaveBaseline = structuredClone(adopted);
105
- if (changes.length) writers.queueConfigChanges(changes);
105
+ if (changes.length) writers.queueConfigChanges([...changes, ...seedChanges(adopted, changes)]);
106
106
  return adopted;
107
107
  }
108
108
 
109
+ // A fresh profile's install markers (an empty `builtins`) and off-by-default
110
+ // agents equal the defaults, so no diff ever carries them — yet on disk
111
+ // their absence reads as a grandfathered profile with every agent on. The
112
+ // first write seeds them where the stored section lacks them.
113
+ function seedChanges(config, changes) {
114
+ return ['builtins', 'disabledAgents']
115
+ .filter((key) => config[key] !== undefined && !changes.some((change) => change.path[0] === key))
116
+ .map((key) => ({ path: [key], value: structuredClone(config[key]), ifAbsent: true }));
117
+ }
118
+
109
119
  function scheduleSkillsSave(names) {
110
120
  // This field belongs to its dedicated writer, not a later unrelated save.
111
121
  configSaveBaseline.skills = structuredClone(getConfig().skills);
@@ -24,7 +24,7 @@ export function createCoreMemoryContext({ getCurrentCwd, getConfig, bootProfile,
24
24
  async function loadCoreMemoryContext() {
25
25
  // User-curated core memory injects into new sessions by default. The
26
26
  // Memory toggle (settings → General) OFF skips ONLY this accumulated
27
- // core-memory block — profile and Project Instructions inject regardless.
27
+ // core-memory block — the profile injects regardless.
28
28
  if (!(featureEnvOverride('MIXDOG_FEATURE_MEMORY') ?? memoryToolsEnabled(getConfig(), true))) {
29
29
  bootProfile('core-memory:disabled');
30
30
  return '';