mixdog 0.9.45 → 0.9.47

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 (123) hide show
  1. package/package.json +5 -2
  2. package/scripts/agent-dispatch-abort-compose-test.mjs +31 -0
  3. package/scripts/agent-model-liveness-test.mjs +618 -0
  4. package/scripts/agent-trace-io-test.mjs +68 -4
  5. package/scripts/bench-run.mjs +30 -3
  6. package/scripts/compact-pressure-test.mjs +187 -4
  7. package/scripts/compact-prior-context-flatten-test.mjs +252 -0
  8. package/scripts/compact-trigger-migration-smoke.mjs +8 -6
  9. package/scripts/compacted-placeholder-scrub-test.mjs +20 -34
  10. package/scripts/context-mcp-metering-test.mjs +75 -0
  11. package/scripts/explore-prompt-policy-test.mjs +15 -16
  12. package/scripts/explore-timeout-cancel-test.mjs +345 -0
  13. package/scripts/headless-pristine-execution-test.mjs +614 -0
  14. package/scripts/internal-comms-smoke.mjs +15 -6
  15. package/scripts/live-worker-smoke.mjs +1 -1
  16. package/scripts/memory-core-input-test.mjs +137 -0
  17. package/scripts/memory-rule-contract-test.mjs +5 -5
  18. package/scripts/openai-oauth-refresh-race-test.mjs +120 -0
  19. package/scripts/openai-oauth-ws-1006-retry-test.mjs +26 -0
  20. package/scripts/parent-abort-link-test.mjs +22 -0
  21. package/scripts/provider-toolcall-test.mjs +22 -0
  22. package/scripts/reactive-compact-persist-smoke.mjs +8 -4
  23. package/scripts/session-bench.mjs +3 -70
  24. package/scripts/task-bench.mjs +0 -2
  25. package/scripts/terminal-bench-isolation-guards-test.mjs +102 -0
  26. package/scripts/tool-smoke.mjs +21 -21
  27. package/scripts/tool-tui-presentation-test.mjs +68 -0
  28. package/scripts/tui-transcript-jitter-harness-entry.jsx +91 -10
  29. package/src/app.mjs +28 -103
  30. package/src/cli.mjs +17 -13
  31. package/src/defaults/cycle3-review-prompt.md +10 -4
  32. package/src/defaults/memory-promote-prompt.md +4 -3
  33. package/src/headless-command.mjs +139 -0
  34. package/src/headless-role.mjs +121 -10
  35. package/src/help.mjs +4 -1
  36. package/src/rules/agent/00-common.md +3 -3
  37. package/src/rules/agent/00-core.md +8 -9
  38. package/src/rules/agent/20-skip-protocol.md +2 -3
  39. package/src/rules/agent/30-explorer.md +50 -56
  40. package/src/rules/agent/40-cycle1-agent.md +10 -12
  41. package/src/rules/agent/41-cycle2-agent.md +12 -9
  42. package/src/rules/agent/42-cycle3-agent.md +4 -6
  43. package/src/rules/lead/01-general.md +5 -6
  44. package/src/rules/lead/02-channels.md +1 -1
  45. package/src/rules/lead/lead-brief.md +14 -17
  46. package/src/rules/lead/lead-tool.md +3 -3
  47. package/src/rules/shared/01-tool.md +41 -43
  48. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +46 -10
  49. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +44 -31
  50. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +18 -3
  51. package/src/runtime/agent/orchestrator/config.mjs +96 -30
  52. package/src/runtime/agent/orchestrator/context/collect.mjs +9 -0
  53. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +3 -0
  54. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +18 -5
  55. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +6 -6
  56. package/src/runtime/agent/orchestrator/providers/gemini.mjs +6 -6
  57. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +123 -3
  58. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +12 -3
  59. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +148 -30
  60. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +5 -7
  61. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +62 -14
  62. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +141 -19
  63. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +12 -4
  64. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +19 -1
  65. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +85 -17
  66. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +6 -8
  67. package/src/runtime/agent/orchestrator/providers/registry.mjs +47 -17
  68. package/src/runtime/agent/orchestrator/session/compact/summary.mjs +159 -20
  69. package/src/runtime/agent/orchestrator/session/context-utils.mjs +83 -10
  70. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +75 -7
  71. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +4 -374
  72. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +0 -75
  73. package/src/runtime/agent/orchestrator/session/loop/transcript-repair.mjs +0 -5
  74. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +20 -3
  75. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +32 -16
  76. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +5 -2
  77. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +86 -15
  78. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +30 -27
  79. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +1 -14
  80. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +21 -27
  81. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +1 -3
  82. package/src/runtime/agent/orchestrator/tools/builtin.mjs +1 -51
  83. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +4 -4
  84. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +1 -1
  85. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +26 -0
  86. package/src/runtime/memory/index.mjs +0 -1
  87. package/src/runtime/memory/lib/core-memory-store.mjs +44 -24
  88. package/src/runtime/memory/lib/http-router.mjs +0 -193
  89. package/src/runtime/memory/lib/memory-action-handlers.mjs +37 -11
  90. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +13 -5
  91. package/src/runtime/memory/lib/memory-cycle2.mjs +8 -5
  92. package/src/runtime/memory/lib/memory-cycle3.mjs +41 -6
  93. package/src/runtime/memory/lib/query-handlers.mjs +49 -6
  94. package/src/runtime/memory/lib/recall-format.mjs +21 -6
  95. package/src/runtime/memory/tool-defs.mjs +5 -6
  96. package/src/runtime/shared/config.mjs +11 -34
  97. package/src/runtime/shared/pristine-execution-contract.json +75 -0
  98. package/src/runtime/shared/pristine-execution.mjs +356 -0
  99. package/src/runtime/shared/provider-api-key.mjs +43 -0
  100. package/src/runtime/shared/provider-auth-binding.mjs +21 -0
  101. package/src/session-runtime/context-status.mjs +61 -13
  102. package/src/session-runtime/mcp-glue.mjs +29 -2
  103. package/src/session-runtime/plugin-mcp.mjs +7 -0
  104. package/src/session-runtime/resource-api.mjs +38 -5
  105. package/src/session-runtime/runtime-core.mjs +5 -1
  106. package/src/session-runtime/session-turn-api.mjs +14 -2
  107. package/src/session-runtime/settings-api.mjs +5 -0
  108. package/src/session-runtime/tool-catalog.mjs +13 -2
  109. package/src/session-runtime/tool-defs.mjs +1 -3
  110. package/src/standalone/agent-task-status.mjs +50 -11
  111. package/src/standalone/agent-tool/tool-def.mjs +1 -1
  112. package/src/standalone/explore-tool.mjs +257 -49
  113. package/src/standalone/seeds.mjs +1 -0
  114. package/src/tui/App.jsx +23 -10
  115. package/src/tui/app/use-transcript-scroll.mjs +4 -3
  116. package/src/tui/app/use-transcript-window.mjs +12 -21
  117. package/src/tui/components/ContextPanel.jsx +19 -25
  118. package/src/tui/dist/index.mjs +77 -65
  119. package/src/tui/engine/agent-envelope.mjs +16 -5
  120. package/src/tui/engine/labels.mjs +1 -1
  121. package/src/workflows/default/WORKFLOW.md +21 -51
  122. package/src/workflows/solo/WORKFLOW.md +12 -17
  123. package/src/workflows/solo-review/WORKFLOW.md +0 -47
@@ -49,7 +49,7 @@ export function createQueryHandlers({
49
49
  // Raw-row priority lookup for narrow-window queries. Raw rows (is_root=0,
50
50
  // chunk_root IS NULL) are inserted immediately by ingestTranscriptFile before
51
51
  // cycle1 runs, so they always carry the freshest turns in the DB.
52
- async function readRawRowsInWindow(db, tsFromMs, tsToMs, hardLimit = 10, { projectScope, sessionId, terms } = {}) {
52
+ async function readRawRowsInWindow(db, tsFromMs, tsToMs, hardLimit = 10, { projectScope, sessionId, terms, minHits: minHitsOverride } = {}) {
53
53
  try {
54
54
  // Composable WHERE assembly (mirrors retrieveEntries' filter semantics so
55
55
  // raw and chunked legs stay in filter parity: projectScope AND sessionId
@@ -78,7 +78,9 @@ export function createQueryHandlers({
78
78
  // into the page. 1-2 term queries keep single-hit contains semantics —
79
79
  // short Korean queries are often exactly two meaningful tokens and a
80
80
  // 2-of-2 requirement silently emptied the raw leg for them.
81
- const minHits = terms.length >= 3 ? 2 : 1
81
+ const minHits = Number.isFinite(Number(minHitsOverride))
82
+ ? Math.max(1, Math.floor(Number(minHitsOverride)))
83
+ : (terms.length >= 3 ? 2 : 1)
82
84
  where.push(`(${clauses.join(' + ')}) >= ${minHits}`)
83
85
  }
84
86
  params.push(hardLimit)
@@ -722,7 +724,7 @@ export function createQueryHandlers({
722
724
  // Deterministic tie-breaker: equal MAX(ts) across sessions would let the
723
725
  // LIMIT/OFFSET window skip or duplicate a session between offset pages
724
726
  // without a stable secondary sort.
725
- const sessSql = `SELECT session_id, MAX(ts) AS last_ts
727
+ const sessSql = `SELECT session_id, MIN(ts) AS first_ts, MAX(ts) AS last_ts
726
728
  FROM entries
727
729
  WHERE ${selWhere.join(' AND ')}
728
730
  GROUP BY session_id
@@ -732,6 +734,7 @@ export function createQueryHandlers({
732
734
  // 2) per selected session, fetch its newest rows (roots+members, sort by
733
735
  // date) plus the fresh raw window, capped at PER_SESSION_ROW_CAP.
734
736
  const allRows = []
737
+ const sessionMeta = new Map()
735
738
  for (const s of sessRows) {
736
739
  const sid = String(s?.session_id || '').trim()
737
740
  if (!sid) continue
@@ -743,7 +746,24 @@ export function createQueryHandlers({
743
746
  const sRows = await retrieveEntries(db, sf)
744
747
  let merged = sRows
745
748
  if (includeRaw) {
746
- const rawRows = await readRawRowsInWindow(db, null, Date.now(), perSessionFetchCap, { projectScope, sessionId: sid, terms: queryTerms })
749
+ let rawRows
750
+ if (queryTerms.length > 0) {
751
+ // Keep a full unfiltered recency window for the newest-row floor,
752
+ // while separately retaining the deeper term-matched raw window.
753
+ const [recentRawRows, matchedRawRows] = await Promise.all([
754
+ readRawRowsInWindow(db, null, Date.now(), perSessionFetchCap, { projectScope, sessionId: sid, terms: [] }),
755
+ readRawRowsInWindow(db, null, Date.now(), perSessionFetchCap, { projectScope, sessionId: sid, terms: queryTerms, minHits: 1 }),
756
+ ])
757
+ const rawIds = new Set()
758
+ rawRows = [...recentRawRows, ...matchedRawRows].filter((r) => {
759
+ const id = Number(r.id)
760
+ if (rawIds.has(id)) return false
761
+ rawIds.add(id)
762
+ return true
763
+ })
764
+ } else {
765
+ rawRows = await readRawRowsInWindow(db, null, Date.now(), perSessionFetchCap, { projectScope, sessionId: sid, terms: [] })
766
+ }
747
767
  const seenIds = new Set(sRows.map((r) => Number(r.id)))
748
768
  for (const r of sRows) if (Array.isArray(r.members)) for (const m of r.members) seenIds.add(Number(m.id))
749
769
  // readRawRowsInWindow carries no category filter, so a category-
@@ -768,14 +788,37 @@ export function createQueryHandlers({
768
788
  merged.sort((a, b) => (Number(b.ts) || 0) - (Number(a.ts) || 0) || (Number(b.id) || 0) - (Number(a.id) || 0))
769
789
  }
770
790
  }
791
+ const fetchedCount = merged.length
792
+ let queryFiltered = false
771
793
  if (queryTerms.length > 0) {
772
- merged = merged.filter(matchesQueryTerms)
794
+ const newestRows = merged.slice(0, 3)
795
+ const newestIds = new Set(newestRows.map((r) => r.id))
796
+ const matchedRows = merged.filter(matchesQueryTerms)
797
+ // A topic query must not obscure a session's latest activity: keep
798
+ // its three newest rows, then use term matches for the remaining
799
+ // display slots without duplicating rows already kept for recency.
800
+ merged = [...newestRows, ...matchedRows.filter((r) => !newestIds.has(r.id))]
801
+ // Only mark query filtering when its floor+match union actually
802
+ // excludes fetched rows. The later display cap is independent.
803
+ queryFiltered = merged.length < fetchedCount
773
804
  }
774
805
  merged = merged.slice(0, PER_SESSION_ROW_CAP)
806
+ sessionMeta.set(sid, {
807
+ minTs: Number(s.first_ts),
808
+ maxTs: Number(s.last_ts),
809
+ fetchedCount,
810
+ shownCount: merged.length,
811
+ queryFiltered,
812
+ })
775
813
  for (const r of merged) allRows.push(r)
776
814
  }
777
815
  const _currentSessionHint = String(args?.currentSessionId || '').trim()
778
- return { text: recallCapPrefix + renderSessionGroupedLines(allRows, { currentSessionId: _currentSessionHint, recencyOrder: true, spanHeaders: true }) }
816
+ return { text: recallCapPrefix + renderSessionGroupedLines(allRows, {
817
+ currentSessionId: _currentSessionHint,
818
+ recencyOrder: true,
819
+ spanHeaders: true,
820
+ sessionMeta,
821
+ }) }
779
822
  }
780
823
 
781
824
  const filters = { limit: limit + offset }
@@ -336,10 +336,16 @@ function spanHeaderSuffix(minTs, maxTs, n) {
336
336
  // group's lines are globally ts-desc: without it a chunk root's members (stored
337
337
  // ts-ASC) interleave with raw rows and invert the visible timeline within a
338
338
  // session (e.g. 04:33 rendered above 04:41).
339
- export function renderSessionGroupedLines(rows, { currentSessionId, recencyOrder = false, spanHeaders = false } = {}) {
340
- if (!rows || rows.length === 0) return '(no results)'
339
+ export function renderSessionGroupedLines(rows, { currentSessionId, recencyOrder = false, spanHeaders = false, sessionMeta } = {}) {
340
+ const hasSessionMeta = spanHeaders && Number(sessionMeta?.size) > 0
341
+ if ((!rows || rows.length === 0) && !hasSessionMeta) return '(no results)'
341
342
  const groups = new Map()
342
- for (const r of rows) {
343
+ // Seed selected sessions so an entirely query-filtered session still
344
+ // reports its real activity span and filtering status.
345
+ if (hasSessionMeta) {
346
+ for (const sid of sessionMeta.keys()) groups.set(sid, [])
347
+ }
348
+ for (const r of rows || []) {
343
349
  const sid = String(r?.session_id || '').trim()
344
350
  const key = sid || '(no session)'
345
351
  if (!groups.has(key)) groups.set(key, [])
@@ -356,11 +362,20 @@ export function renderSessionGroupedLines(rows, { currentSessionId, recencyOrder
356
362
  for (const [sid, groupRows] of groups) {
357
363
  const mark = current && sid === current ? ' (current)' : ''
358
364
  const label = sid === '(no session)' ? sid : `session ${shortSessionLabel(sid)}`
365
+ const meta = sessionMeta?.get?.(sid)
359
366
  const tsAll = collectGroupTs(groupRows)
360
- const suffix = tsAll.length
361
- ? ` ${spanHeaderSuffix(Math.min(...tsAll), Math.max(...tsAll), groupRows.length)}`
367
+ const minTs = Number(meta?.minTs)
368
+ const maxTs = Number(meta?.maxTs)
369
+ const hasActivitySpan = Number.isFinite(minTs) && Number.isFinite(maxTs)
370
+ const suffix = hasActivitySpan
371
+ ? ` ${spanHeaderSuffix(minTs, maxTs, groupRows.length)}`
372
+ : tsAll.length
373
+ ? ` ${spanHeaderSuffix(Math.min(...tsAll), Math.max(...tsAll), groupRows.length)}`
362
374
  : ` (${groupRows.length} entries)`
363
- parts.push(`## ${label}${mark}${suffix}`)
375
+ const filterNote = meta?.queryFiltered
376
+ ? ` · query-filtered ${meta.shownCount}/${meta.fetchedCount} rows`
377
+ : ''
378
+ parts.push(`## ${label}${mark}${suffix}${filterNote}`)
364
379
  const bodyStr = renderEntryLines(groupRows, { recencyOrder })
365
380
  const bodyLines = bodyStr === '(no results)'
366
381
  ? []
@@ -8,20 +8,20 @@ export const TOOL_DEFS = [
8
8
  name: 'memory',
9
9
  title: 'Memory Cycle',
10
10
  annotations: { title: 'Memory Cycle', readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
11
- description: 'Core-memory mutation and status. Use recall for retrieval. Persist with action:"core" op:"add" when: user corrects/confirms your approach, states a durable preference, or says "remember". Core add/edit requires project_id + category + element + summary. Only what code/git/docs cannot derive; include the why. Never transient task state. No prior recall dedup needed — the cycle dedups.',
11
+ description: 'Core-memory mutation and status; use recall for retrieval. Store durable rules/preferences/facts, one compact ENGLISH clause each never transient task state. add requires project_id+category+summary; edit works by id alone.',
12
12
  inputSchema: {
13
13
  type: 'object',
14
14
  properties: {
15
15
  action: { type: 'string', enum: ['core','status'], description: 'Operation.' },
16
16
  op: { type: 'string', enum: ['add','edit','delete','list','candidates','promote','dismiss'], description: 'Mutation op. candidates/promote/dismiss drive core-memory proposal approval.' },
17
17
  id: { type: 'number', description: 'Exact memory id.' },
18
- element: { type: 'string', maxLength: 40, description: 'Memory key/title. Max 40 chars.' },
19
- summary: { type: 'string', maxLength: 100, description: 'Memory content: 1 fact, 1-2 sentences, max 100 chars.' },
18
+ element: { type: 'string', maxLength: 40, description: 'Memory key/title. Defaults to the first 40 chars of summary. Max 40 chars.' },
19
+ summary: { type: 'string', maxLength: 100, description: 'Memory content: one short English clause, max 100 chars.' },
20
20
  category: { type: 'string', enum: ['rule','constraint','decision','fact','goal','preference','task','issue'], description: 'Category.' },
21
21
  status: { type: 'string', enum: ['pending','active','archived'], description: 'Lifecycle status.' },
22
22
  limit: { type: 'number', description: 'Max rows/items.' },
23
23
  confirm: { type: 'string', description: 'Exact confirmation phrase for destructive actions.' },
24
- project_id: { type: 'string', description: 'Core pool: common, slug, or *. Required for core add/edit; there is no default pool.' },
24
+ project_id: { type: 'string', description: 'Core pool: explicit common or slug. Required for core add only (edit uses the id\'s stored pool); there is no default pool.' },
25
25
  },
26
26
  additionalProperties: false,
27
27
  required: ['action'],
@@ -31,7 +31,7 @@ export const TOOL_DEFS = [
31
31
  name: 'recall',
32
32
  title: 'Recall',
33
33
  annotations: { title: 'Recall', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
34
- description: 'Retrieve stored memory/session history. Call when a task ties to prior work resumes, continuations, or references to earlier decisions/messages. Do not call as a reflexive pre-step, to verify a just-made decision, or before storing memory. Query is topic/semantic search, not regex. Patterns: period:"last" browses recent sessions; +query narrows topic; sessionId without query for current session; period:"3h"/"30m"/"24h" for recent; YYYY-MM-DD, date ranges, or HH:MM~HH:MM for time windows; id for exact follow-up.',
34
+ description: 'Retrieve stored memory/session history for prior-work context (resumes, earlier decisions); not a reflexive pre-step. Query is topic/semantic, not regex; period selects the time window; id for exact follow-up.',
35
35
  inputSchema: {
36
36
  type: 'object',
37
37
  properties: {
@@ -44,7 +44,6 @@ export const TOOL_DEFS = [
44
44
  category: { anyOf: [{ type: 'string', enum: ['rule','constraint','decision','fact','goal','preference','task','issue'] }, { type: 'array', items: { type: 'string', enum: ['rule','constraint','decision','fact','goal','preference','task','issue'] }, minItems: 1 }], description: 'Category filter.' },
45
45
  includeArchived: { type: 'boolean', description: 'Include archived entries.' },
46
46
  sessionId: { type: 'string', description: 'Scoped session id.' },
47
- session_id: { type: 'string', description: 'Alias for sessionId.' },
48
47
  includeMembers: { type: 'boolean', description: 'Include chunk members in output; does not widen the search pool.' },
49
48
  includeRaw: { type: 'boolean', description: 'Include unchunked raw/episode rows.' },
50
49
  sessionOnly: { type: 'boolean', description: 'Search this session only.' },
@@ -14,6 +14,17 @@ import {
14
14
  loadLatestMixdogConfigFromBackup,
15
15
  hasUserDataInitMarker,
16
16
  } from './user-data-guard.mjs'
17
+ import {
18
+ AGENT_PROVIDER_ENV,
19
+ AGENT_PROVIDER_ENV_ALIASES,
20
+ getAgentApiKey,
21
+ } from './provider-api-key.mjs'
22
+
23
+ export {
24
+ AGENT_PROVIDER_ENV,
25
+ AGENT_PROVIDER_ENV_ALIASES,
26
+ getAgentApiKey,
27
+ }
17
28
 
18
29
  const _require = createRequire(import.meta.url)
19
30
  const { getSecret: _getSecret, setSecret: _setSecret, deleteSecret: _deleteSecret, hasSecret: _hasSecret } = _require('../../lib/keychain-cjs.cjs')
@@ -453,40 +464,6 @@ export function getWebhookAuthtoken() {
453
464
  return _readSecret(SECRET_ACCOUNTS.webhookAuth)
454
465
  }
455
466
 
456
- // Standard provider env names take precedence so existing OPENAI_API_KEY-style
457
- // exports keep working, then MIXDOG_AGENT_<P>_APIKEY, then the OS keychain.
458
- // SSOT for agent API-key providers: setup-server.mjs and config-merge.mjs import
459
- // this so UI key-presence detection uses the exact same predicate the runtime
460
- // loads from. Add a provider here and every path picks it up.
461
- export const AGENT_PROVIDER_ENV = Object.freeze({
462
- openai: 'OPENAI_API_KEY', anthropic: 'ANTHROPIC_API_KEY', gemini: 'GEMINI_API_KEY',
463
- deepseek: 'DEEPSEEK_API_KEY', xai: 'XAI_API_KEY', 'opencode-go': 'OPENCODE_API_KEY',
464
- })
465
-
466
- // Last-resort env aliases honored AFTER the standard env / MIXDOG_AGENT_* /
467
- // keychain sources. GROK_API_KEY is the established xAI alias elsewhere in the
468
- // repo (search discovery, xai-api/grok-oauth backends), so honoring it here
469
- // keeps provider discovery and dispatch resolving the same credential.
470
- export const AGENT_PROVIDER_ENV_ALIASES = Object.freeze({
471
- xai: ['GROK_API_KEY'],
472
- })
473
-
474
- /**
475
- * Returns the API key for an agent provider.
476
- * Priority: <PROVIDER>_API_KEY env -> MIXDOG_AGENT_<PROVIDER>_APIKEY -> keychain('agent.<provider>.apiKey') -> alias env (e.g. GROK_API_KEY for xai) -> null.
477
- * Never reads mixdog-config.json — provider keys are keychain-only.
478
- */
479
- export function getAgentApiKey(provider) {
480
- const std = AGENT_PROVIDER_ENV[provider]
481
- if (std && process.env[std]) return process.env[std]
482
- const fromStore = _readSecret(SECRET_ACCOUNTS.agentApiKey(provider))
483
- if (fromStore) return fromStore
484
- for (const alias of AGENT_PROVIDER_ENV_ALIASES[provider] || []) {
485
- if (process.env[alias]) return process.env[alias]
486
- }
487
- return null
488
- }
489
-
490
467
  export function getOpenAIUsageSessionKey() {
491
468
  return process.env.OPENAI_USAGE_SESSION_KEY
492
469
  || process.env.OPENAI_DASHBOARD_SESSION_KEY
@@ -0,0 +1,75 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "configFileName": "mixdog-config.json",
4
+ "guardEnv": {
5
+ "MIXDOG_DISABLE_MCP": "1",
6
+ "MIXDOG_DISABLE_SKILLS": "1",
7
+ "MIXDOG_BOOT_CORE_MEMORY": "0",
8
+ "MIXDOG_DISABLE_CHANNEL_START": "1",
9
+ "MIXDOG_SKIP_USER_DATA_BACKUP": "1",
10
+ "MIXDOG_DISABLE_STAGED_INSTALL": "1",
11
+ "MIXDOG_DISABLE_STAGED_SWAP": "1"
12
+ },
13
+ "approvedExecutionEnv": [
14
+ "MIXDOG_AGENT_TRACE_PATH",
15
+ "MIXDOG_ANTHROPIC_OAUTH_REFRESH_DISABLED",
16
+ "MIXDOG_DRIVER_DEADLINE_MS",
17
+ "MIXDOG_DISABLE_PROVIDER_WARMUP",
18
+ "MIXDOG_DISABLE_MODEL_PREFETCH",
19
+ "MIXDOG_DISABLE_MODEL_CATALOG_WARMUP",
20
+ "MIXDOG_PROVIDER_FIRST_BYTE_TIMEOUT_MS",
21
+ "MIXDOG_STALL_FIRST_BYTE_ABORT_S"
22
+ ],
23
+ "benchmarkRouteEnv": [
24
+ "MIXDOG_PROMPT",
25
+ "MIXDOG_PROVIDER",
26
+ "MIXDOG_MODEL",
27
+ "MIXDOG_EFFORT",
28
+ "MIXDOG_FAST",
29
+ "MIXDOG_WORKFLOW"
30
+ ],
31
+ "oauthProviders": {
32
+ "anthropic-oauth": {
33
+ "credentialFile": "anthropic-oauth-credentials.json",
34
+ "credentialPathEnv": "ANTHROPIC_OAUTH_CREDENTIALS_PATH",
35
+ "modelCatalogFile": "anthropic-oauth-models.json"
36
+ },
37
+ "openai-oauth": {
38
+ "credentialFile": "openai-oauth.json",
39
+ "credentialPathEnv": "OPENAI_OAUTH_CREDENTIALS_PATH",
40
+ "modelCatalogFile": "openai-oauth-models.json"
41
+ },
42
+ "grok-oauth": {
43
+ "credentialFile": "grok-oauth.json",
44
+ "credentialPathEnv": "GROK_OAUTH_CREDENTIALS_PATH",
45
+ "modelCatalogFile": "grok-oauth-models.json"
46
+ }
47
+ },
48
+ "apiKeyProviders": {
49
+ "openai": "OPENAI_API_KEY",
50
+ "anthropic": "ANTHROPIC_API_KEY",
51
+ "gemini": "GEMINI_API_KEY",
52
+ "deepseek": "DEEPSEEK_API_KEY",
53
+ "xai": "XAI_API_KEY",
54
+ "opencode-go": "OPENCODE_API_KEY"
55
+ },
56
+ "personalStateCounters": [
57
+ "behavioralStateFilesCopied",
58
+ "memoryFilesCopied",
59
+ "sessionFilesCopied",
60
+ "userProfileFilesCopied",
61
+ "pluginStateFilesCopied",
62
+ "channelStateFilesCopied",
63
+ "otherPersonalStateFilesCopied"
64
+ ],
65
+ "disabledFeatures": [
66
+ "mcpSources",
67
+ "skills",
68
+ "bundledSkillSeeding",
69
+ "coreMemoryBoot",
70
+ "channelStart",
71
+ "plugins",
72
+ "userProfiles",
73
+ "priorSessions"
74
+ ]
75
+ }
@@ -0,0 +1,356 @@
1
+ import { createHash } from 'node:crypto';
2
+ import {
3
+ copyFileSync,
4
+ existsSync,
5
+ mkdirSync,
6
+ mkdtempSync,
7
+ readFileSync,
8
+ rmSync,
9
+ writeFileSync,
10
+ } from 'node:fs';
11
+ import { homedir, tmpdir } from 'node:os';
12
+ import { join, resolve } from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+ import { replaceProviderAuthBindings } from './provider-auth-binding.mjs';
15
+ import {
16
+ AGENT_PROVIDER_ENV_ALIASES,
17
+ getAgentApiKey,
18
+ } from './provider-api-key.mjs';
19
+
20
+ const contractPath = fileURLToPath(
21
+ new URL('./pristine-execution-contract.json', import.meta.url),
22
+ );
23
+ const patchManifestPath = fileURLToPath(
24
+ new URL('../agent/orchestrator/tools/patch-manifest.json', import.meta.url),
25
+ );
26
+
27
+ export const PRISTINE_EXECUTION_CONTRACT = Object.freeze(
28
+ JSON.parse(readFileSync(contractPath, 'utf8')),
29
+ );
30
+
31
+ const EFFORTS = new Set(['low', 'medium', 'high', 'xhigh', 'max']);
32
+
33
+ function clean(value) {
34
+ return String(value ?? '').trim();
35
+ }
36
+
37
+ export function validateExplicitPristineRoute({ provider, model, effort, fast } = {}) {
38
+ const selectedProvider = clean(provider);
39
+ const selectedModel = clean(model);
40
+ if (!selectedProvider || !selectedModel) {
41
+ return 'headless role commands require both --provider <name> and --model <name>; host route fallback is disabled';
42
+ }
43
+ const selectedEffort = clean(effort).toLowerCase();
44
+ if (selectedEffort && !EFFORTS.has(selectedEffort)) {
45
+ return `invalid --effort ${JSON.stringify(clean(effort))}; expected low, medium, high, xhigh, or max`;
46
+ }
47
+ if (fast !== undefined && typeof fast !== 'boolean') {
48
+ return '--fast must be an explicit boolean flag';
49
+ }
50
+ return null;
51
+ }
52
+
53
+ export function buildMinimalPristineConfig({ provider, model, effort, fast } = {}) {
54
+ const routeError = validateExplicitPristineRoute({ provider, model, effort, fast });
55
+ if (routeError) throw new Error(routeError);
56
+ const selectedProvider = clean(provider);
57
+ const selectedModel = clean(model);
58
+ const selectedEffort = clean(effort).toLowerCase();
59
+ const route = {
60
+ provider: selectedProvider,
61
+ model: selectedModel,
62
+ ...(selectedEffort ? { effort: selectedEffort } : {}),
63
+ ...(fast === true ? { fast: true } : {}),
64
+ };
65
+ const providerConfig = {
66
+ enabled: true,
67
+ ...(selectedProvider === 'openai-oauth' ? { websocket: true } : {}),
68
+ };
69
+ const modelSettings = {};
70
+ if (selectedEffort || fast === true) {
71
+ modelSettings[`${selectedProvider}/${selectedModel}`] = {
72
+ ...(selectedEffort ? { effort: selectedEffort } : {}),
73
+ ...(fast === true ? { fast: true } : {}),
74
+ };
75
+ }
76
+ return {
77
+ agent: {
78
+ providers: { [selectedProvider]: providerConfig },
79
+ presets: [{
80
+ id: 'headless-explicit-route',
81
+ name: 'HEADLESS EXPLICIT ROUTE',
82
+ type: 'agent',
83
+ tools: 'full',
84
+ ...route,
85
+ }],
86
+ default: 'headless-explicit-route',
87
+ workflow: { active: 'default' },
88
+ workflowRoutes: {},
89
+ agents: {},
90
+ modelSettings,
91
+ mcpServers: {},
92
+ },
93
+ };
94
+ }
95
+
96
+ function cloneJson(value) {
97
+ return JSON.parse(JSON.stringify(value));
98
+ }
99
+
100
+ export function createSelectedProviderPristineLoader({
101
+ config,
102
+ provider,
103
+ apiKey = null,
104
+ } = {}) {
105
+ const selectedProvider = clean(provider);
106
+ const runtimeConfig = cloneJson(config?.agent || {});
107
+ const selectedConfig = {
108
+ ...(runtimeConfig.providers?.[selectedProvider] || {}),
109
+ enabled: true,
110
+ ...(apiKey ? { apiKey } : {}),
111
+ };
112
+ runtimeConfig.providers = { [selectedProvider]: selectedConfig };
113
+ return () => cloneJson(runtimeConfig);
114
+ }
115
+
116
+ function hostDataDir(env) {
117
+ if (clean(env.MIXDOG_DATA_DIR)) return resolve(env.MIXDOG_DATA_DIR);
118
+ const home = clean(env.MIXDOG_HOME)
119
+ ? resolve(env.MIXDOG_HOME)
120
+ : join(homedir(), '.mixdog');
121
+ return join(home, 'data');
122
+ }
123
+
124
+ function patchPlatformKey() {
125
+ const os = process.platform === 'win32' ? 'win32' : process.platform;
126
+ return `${os}-${process.arch}`;
127
+ }
128
+
129
+ export function seedVerifiedPatchBinaryCache(
130
+ sourceDataDir,
131
+ dataDir,
132
+ { manifestPath = patchManifestPath } = {},
133
+ ) {
134
+ try {
135
+ const sourcePatchDir = join(sourceDataDir, 'patch-bin');
136
+ const manifestBytes = readFileSync(manifestPath);
137
+ const manifest = JSON.parse(manifestBytes.toString('utf8'));
138
+ const asset = manifest?.assets?.[patchPlatformKey()];
139
+ const version = String(manifest?.version || '');
140
+ const expectedSha256 = clean(asset?.sha256).toLowerCase();
141
+ if (!/^[A-Za-z0-9._-]+$/.test(version) || !/^[a-f0-9]{64}$/.test(expectedSha256)) return false;
142
+
143
+ const binaryName = `mixdog-patch-${version}${process.platform === 'win32' ? '.exe' : ''}`;
144
+ const sourceBinary = join(sourcePatchDir, binaryName);
145
+ if (!existsSync(sourceBinary)) return false;
146
+ const binaryBytes = readFileSync(sourceBinary);
147
+ const actualSha256 = createHash('sha256').update(binaryBytes).digest('hex');
148
+ if (actualSha256 !== expectedSha256) return false;
149
+
150
+ const targetPatchDir = join(dataDir, 'patch-bin');
151
+ mkdirSync(targetPatchDir, { recursive: true, mode: 0o700 });
152
+ writeFileSync(join(targetPatchDir, binaryName), binaryBytes, { mode: 0o700 });
153
+ writeFileSync(join(targetPatchDir, 'manifest.json'), manifestBytes, { mode: 0o600 });
154
+ return true;
155
+ } catch {
156
+ // A missing, stale, or malformed host cache is optional acceleration only.
157
+ // Leave the pristine runtime empty so the normal fetch path handles it.
158
+ return false;
159
+ }
160
+ }
161
+
162
+ function auditDocument({
163
+ provider,
164
+ model,
165
+ effort,
166
+ fast,
167
+ configBytes,
168
+ authMode,
169
+ catalogCount,
170
+ }) {
171
+ const contract = PRISTINE_EXECUTION_CONTRACT;
172
+ return {
173
+ schemaVersion: contract.schemaVersion,
174
+ mode: 'headless-role-pristine',
175
+ provider: clean(provider),
176
+ model: clean(model),
177
+ effort: clean(effort).toLowerCase() || null,
178
+ fast: fast === true,
179
+ configSha256: createHash('sha256').update(configBytes).digest('hex'),
180
+ authMode,
181
+ authArtifactFilesCopied: 0,
182
+ injectedModelCatalogFileCount: catalogCount,
183
+ personalState: {
184
+ hostConfigRead: false,
185
+ ...Object.fromEntries(contract.personalStateCounters.map((name) => [name, 0])),
186
+ },
187
+ featuresEnabled: Object.fromEntries(
188
+ contract.disabledFeatures.map((name) => [name, false]),
189
+ ),
190
+ };
191
+ }
192
+
193
+ export function formatPristineExecutionAudit(audit) {
194
+ return [
195
+ `pristine-execution-audit v${audit.schemaVersion}`,
196
+ 'mode=headless-role',
197
+ 'personal-files=0',
198
+ 'host-config=0',
199
+ 'mcp=0',
200
+ 'skills=0',
201
+ 'core-memory=0',
202
+ 'channels=0',
203
+ 'plugins=0',
204
+ 'profiles=0',
205
+ 'prior-sessions=0',
206
+ `provider=${audit.provider}`,
207
+ `model=${audit.model}`,
208
+ `effort=${audit.effort || 'provider-default'}`,
209
+ `fast=${String(audit.fast)}`,
210
+ `auth=${audit.authMode}`,
211
+ `catalogs=${audit.injectedModelCatalogFileCount}`,
212
+ ].join(' ');
213
+ }
214
+
215
+ export function createPristineExecutionBoundary({
216
+ provider,
217
+ model,
218
+ effort,
219
+ fast,
220
+ env = process.env,
221
+ approvedExecutionEnv = {},
222
+ apiKeyResolver = getAgentApiKey,
223
+ } = {}) {
224
+ const routeError = validateExplicitPristineRoute({ provider, model, effort, fast });
225
+ if (routeError) throw new Error(routeError);
226
+
227
+ const contract = PRISTINE_EXECUTION_CONTRACT;
228
+ const hostEnv = { ...env };
229
+ const originalEnv = new Map();
230
+ const touchedKeys = new Set();
231
+ const setEnv = (name, value) => {
232
+ if (!touchedKeys.has(name)) {
233
+ originalEnv.set(name, Object.prototype.hasOwnProperty.call(env, name) ? env[name] : undefined);
234
+ touchedKeys.add(name);
235
+ }
236
+ env[name] = String(value);
237
+ };
238
+ const unsetEnv = (name) => {
239
+ if (!touchedKeys.has(name)) {
240
+ originalEnv.set(name, Object.prototype.hasOwnProperty.call(env, name) ? env[name] : undefined);
241
+ touchedKeys.add(name);
242
+ }
243
+ delete env[name];
244
+ };
245
+ const rootDir = mkdtempSync(join(tmpdir(), 'mixdog-headless-pristine-'));
246
+ const dataDir = join(rootDir, 'data');
247
+ mkdirSync(dataDir, { recursive: true, mode: 0o700 });
248
+ let cleaned = false;
249
+ let restoreAuthBindings = () => {};
250
+ const cleanup = () => {
251
+ if (cleaned) return;
252
+ cleaned = true;
253
+ for (const [name, value] of originalEnv) {
254
+ if (value === undefined) delete env[name];
255
+ else env[name] = value;
256
+ }
257
+ restoreAuthBindings();
258
+ rmSync(rootDir, { recursive: true, force: true });
259
+ };
260
+
261
+ try {
262
+ const sourceDataDir = hostDataDir(hostEnv);
263
+ seedVerifiedPatchBinaryCache(sourceDataDir, dataDir);
264
+ const approvedNames = new Set(contract.approvedExecutionEnv || []);
265
+ for (const name of Object.keys(approvedExecutionEnv || {})) {
266
+ if (!approvedNames.has(name)) {
267
+ throw new Error(`unapproved pristine execution environment override: ${name}`);
268
+ }
269
+ }
270
+ const inheritedExecutionEnv = Object.fromEntries(
271
+ [...approvedNames]
272
+ .filter((name) => hostEnv[name] !== undefined)
273
+ .map((name) => [name, hostEnv[name]]),
274
+ );
275
+ const selectedProvider = clean(provider);
276
+ const oauth = contract.oauthProviders[selectedProvider];
277
+ const apiKeyEnv = contract.apiKeyProviders[selectedProvider];
278
+ let selectedApiKey = null;
279
+ if (apiKeyEnv) {
280
+ // Resolve before scrubbing so the selected provider may use its standard,
281
+ // MIXDOG_AGENT_*, alias, or one provider-scoped keychain account. No
282
+ // general config loader or all-provider credential scan is involved.
283
+ selectedApiKey = clean(apiKeyResolver(selectedProvider));
284
+ }
285
+ // Fail closed: no inherited Mixdog behavior reaches the child runtime.
286
+ // Only contract-listed operational controls are reintroduced below.
287
+ for (const name of Object.keys(env)) {
288
+ if (name.startsWith('MIXDOG_')) unsetEnv(name);
289
+ }
290
+ for (const entry of Object.values(contract.oauthProviders)) {
291
+ unsetEnv(entry.credentialPathEnv);
292
+ }
293
+ for (const name of Object.values(contract.apiKeyProviders)) unsetEnv(name);
294
+ for (const aliases of Object.values(AGENT_PROVIDER_ENV_ALIASES)) {
295
+ for (const name of aliases) unsetEnv(name);
296
+ }
297
+ setEnv('MIXDOG_HOME', rootDir);
298
+ setEnv('MIXDOG_DATA_DIR', dataDir);
299
+ for (const [name, value] of Object.entries({
300
+ ...inheritedExecutionEnv,
301
+ ...approvedExecutionEnv,
302
+ })) {
303
+ setEnv(name, value);
304
+ }
305
+ for (const [name, value] of Object.entries(contract.guardEnv)) setEnv(name, value);
306
+
307
+ let authMode = 'provider-managed';
308
+ let catalogCount = 0;
309
+ if (oauth) {
310
+ const sourceCredential = clean(hostEnv[oauth.credentialPathEnv])
311
+ ? resolve(hostEnv[oauth.credentialPathEnv])
312
+ : join(sourceDataDir, oauth.credentialFile);
313
+ if (!existsSync(sourceCredential)) {
314
+ throw new Error(`required ${clean(provider)} credentials are unavailable; sign in before using the headless role command`);
315
+ }
316
+ // Authentication is the sole host-state exception. Bind in-process (not
317
+ // via child-visible environment) so tools cannot discover the auth path.
318
+ restoreAuthBindings = replaceProviderAuthBindings({
319
+ [selectedProvider]: sourceCredential,
320
+ });
321
+ authMode = 'in-process-host-oauth-binding';
322
+ const sourceCatalog = join(sourceDataDir, oauth.modelCatalogFile);
323
+ if (existsSync(sourceCatalog)) {
324
+ copyFileSync(sourceCatalog, join(dataDir, oauth.modelCatalogFile));
325
+ catalogCount = 1;
326
+ }
327
+ } else if (apiKeyEnv) {
328
+ if (!selectedApiKey) {
329
+ throw new Error(`required ${selectedProvider} authentication is unavailable; set ${apiKeyEnv}`);
330
+ }
331
+ authMode = 'in-process-api-key';
332
+ }
333
+
334
+ const config = buildMinimalPristineConfig({ provider, model, effort, fast });
335
+ const loadConfig = createSelectedProviderPristineLoader({
336
+ config,
337
+ provider,
338
+ apiKey: selectedApiKey,
339
+ });
340
+ const configBytes = `${JSON.stringify(config, null, 2)}\n`;
341
+ writeFileSync(join(dataDir, contract.configFileName), configBytes, { mode: 0o600 });
342
+ const audit = auditDocument({
343
+ provider,
344
+ model,
345
+ effort,
346
+ fast,
347
+ configBytes,
348
+ authMode,
349
+ catalogCount,
350
+ });
351
+ return { rootDir, dataDir, config, audit, loadConfig, cleanup };
352
+ } catch (error) {
353
+ cleanup();
354
+ throw error;
355
+ }
356
+ }