mixdog 0.9.25 → 0.9.26

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 (68) hide show
  1. package/package.json +1 -1
  2. package/scripts/steering-fold-provenance-test.mjs +71 -0
  3. package/scripts/webhook-smoke.mjs +46 -53
  4. package/src/defaults/skills/setup/SKILL.md +6 -1
  5. package/src/rules/shared/01-tool.md +8 -4
  6. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +17 -0
  7. package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +2 -2
  8. package/src/runtime/agent/orchestrator/mcp/client.mjs +28 -10
  9. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +8 -1
  10. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +14 -1
  11. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +51 -15
  12. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +5 -1
  13. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +164 -9
  14. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +45 -0
  15. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +4 -0
  16. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +12 -1
  17. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +2 -2
  18. package/src/runtime/agent/orchestrator/tools/shell-state.mjs +7 -3
  19. package/src/runtime/channels/lib/config.mjs +13 -11
  20. package/src/runtime/channels/lib/memory-client.mjs +22 -2
  21. package/src/runtime/channels/lib/owned-runtime.mjs +1 -1
  22. package/src/runtime/channels/lib/scheduler.mjs +226 -208
  23. package/src/runtime/channels/lib/status-snapshot.mjs +16 -0
  24. package/src/runtime/channels/lib/webhook/deliveries.mjs +7 -318
  25. package/src/runtime/channels/lib/webhook.mjs +98 -150
  26. package/src/runtime/channels/lib/worker-main.mjs +1 -1
  27. package/src/runtime/memory/index.mjs +50 -25
  28. package/src/runtime/memory/lib/cycle-scheduler.mjs +3 -1
  29. package/src/runtime/memory/lib/memory-config-flags.mjs +13 -1
  30. package/src/runtime/memory/lib/pg/adapter.mjs +6 -1
  31. package/src/runtime/memory/lib/pg/process.mjs +19 -1
  32. package/src/runtime/memory/lib/pg/supervisor.mjs +125 -14
  33. package/src/runtime/memory/lib/query-handlers.mjs +102 -0
  34. package/src/runtime/memory/lib/recall-format.mjs +60 -22
  35. package/src/runtime/memory/lib/session-ingest-runtime.mjs +20 -6
  36. package/src/runtime/memory/tool-defs.mjs +1 -1
  37. package/src/runtime/shared/child-guardian.mjs +2 -2
  38. package/src/runtime/shared/open-url.mjs +2 -1
  39. package/src/runtime/shared/schedules-db.mjs +350 -0
  40. package/src/runtime/shared/service-discovery.mjs +169 -0
  41. package/src/runtime/shared/spawn-flags.mjs +27 -0
  42. package/src/runtime/shared/tool-primitives.mjs +3 -1
  43. package/src/runtime/shared/tool-surface.mjs +19 -3
  44. package/src/runtime/shared/update-checker.mjs +54 -10
  45. package/src/runtime/shared/webhooks-db.mjs +405 -0
  46. package/src/session-runtime/channel-config-api.mjs +13 -13
  47. package/src/session-runtime/lifecycle-api.mjs +14 -0
  48. package/src/session-runtime/runtime-core.mjs +39 -20
  49. package/src/standalone/agent-tool.mjs +42 -11
  50. package/src/standalone/channel-admin.mjs +173 -121
  51. package/src/standalone/channel-worker.mjs +2 -2
  52. package/src/standalone/memory-runtime-proxy.mjs +10 -5
  53. package/src/tui/App.jsx +32 -10
  54. package/src/tui/app/channel-pickers.mjs +9 -9
  55. package/src/tui/app/clipboard.mjs +14 -0
  56. package/src/tui/app/doctor.mjs +1 -1
  57. package/src/tui/app/maintenance-pickers.mjs +17 -7
  58. package/src/tui/app/settings-picker.mjs +2 -2
  59. package/src/tui/app/transcript-window.mjs +37 -1
  60. package/src/tui/app/use-mouse-input.mjs +19 -6
  61. package/src/tui/components/ToolExecution.jsx +12 -4
  62. package/src/tui/display-width.mjs +10 -4
  63. package/src/tui/dist/index.mjs +129 -55
  64. package/src/tui/engine/session-api-ext.mjs +12 -12
  65. package/src/tui/index.jsx +12 -2
  66. package/src/ui/statusline-segments.mjs +12 -1
  67. package/vendor/ink/build/display-width.js +5 -4
  68. package/src/runtime/shared/schedules-store.mjs +0 -82
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mixdog",
3
- "version": "0.9.25",
3
+ "version": "0.9.26",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Standalone mixdog coding-agent CLI/TUI workspace.",
@@ -0,0 +1,71 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { foldUserTextIntoToolResultTail } from '../src/runtime/agent/orchestrator/session/context-utils.mjs';
4
+ import { _buildRequestBodyForCacheSmoke } from '../src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs';
5
+
6
+ // A tool_result turn followed by a plain user text turn.
7
+ function baseTranscript(userMeta) {
8
+ return [
9
+ { role: 'user', content: 'do the thing' },
10
+ { role: 'assistant', content: '', toolCalls: [{ id: 't1', name: 'read', arguments: {} }] },
11
+ { role: 'tool', toolCallId: 't1', content: 'file contents here' },
12
+ { role: 'user', content: 'actually stop and do X instead', ...(userMeta ? { meta: userMeta } : {}) },
13
+ ];
14
+ }
15
+
16
+ test('plain user text after tool_result is folded into the tool_result turn', () => {
17
+ const body = _buildRequestBodyForCacheSmoke(baseTranscript(null), 'claude-sonnet-4');
18
+ const msgs = body.messages;
19
+ const lastUser = msgs[msgs.length - 1];
20
+ assert.equal(lastUser.role, 'user');
21
+ const hasToolResult = lastUser.content.some((b) => b.type === 'tool_result');
22
+ assert.ok(hasToolResult, 'trailing user turn carries the tool_result');
23
+ assert.ok(JSON.stringify(lastUser.content).includes('do X instead'), 'plain text folded into tool_result');
24
+ });
25
+
26
+ test('steering-tagged user text after tool_result stays a separate user turn', () => {
27
+ const body = _buildRequestBodyForCacheSmoke(baseTranscript({ source: 'steering' }), 'claude-sonnet-4');
28
+ const msgs = body.messages;
29
+ const lastUser = msgs[msgs.length - 1];
30
+ assert.equal(lastUser.role, 'user', 'steering message is its own user turn');
31
+ const isToolResultTurn = Array.isArray(lastUser.content)
32
+ && lastUser.content.some((b) => b.type === 'tool_result');
33
+ assert.equal(isToolResultTurn, false, 'steering turn is not a tool_result turn');
34
+ assert.ok(JSON.stringify(lastUser.content).includes('do X instead'), 'steering text preserved as user input');
35
+ const prev = msgs[msgs.length - 2];
36
+ assert.ok(
37
+ Array.isArray(prev.content) && prev.content.some((b) => b.type === 'tool_result'),
38
+ 'tool_result stays on its own preceding turn',
39
+ );
40
+ });
41
+
42
+ test('foldUserTextIntoToolResultTail unit: folds plain text tail', () => {
43
+ const result = [{ role: 'user', content: [{ type: 'tool_result', tool_use_id: 't1', content: 'r' }] }];
44
+ assert.equal(foldUserTextIntoToolResultTail(result, 'hi'), true);
45
+ });
46
+
47
+ // --- API-key Anthropic provider (anthropic.mjs) path ---
48
+ import { _toAnthropicMessagesForTest } from '../src/runtime/agent/orchestrator/providers/anthropic.mjs';
49
+
50
+ test('anthropic.mjs: plain user text after tool_result is folded', () => {
51
+ const msgs = _toAnthropicMessagesForTest(baseTranscript(null));
52
+ const lastUser = msgs[msgs.length - 1];
53
+ assert.equal(lastUser.role, 'user');
54
+ assert.ok(lastUser.content.some((b) => b.type === 'tool_result'), 'trailing turn carries tool_result');
55
+ assert.ok(JSON.stringify(lastUser.content).includes('do X instead'), 'plain text folded into tool_result');
56
+ });
57
+
58
+ test('anthropic.mjs: steering-tagged user text stays a separate user turn', () => {
59
+ const msgs = _toAnthropicMessagesForTest(baseTranscript({ source: 'steering' }));
60
+ const lastUser = msgs[msgs.length - 1];
61
+ assert.equal(lastUser.role, 'user', 'steering message is its own user turn');
62
+ const isToolResultTurn = Array.isArray(lastUser.content)
63
+ && lastUser.content.some((b) => b.type === 'tool_result');
64
+ assert.equal(isToolResultTurn, false, 'steering turn is not a tool_result turn');
65
+ assert.ok(JSON.stringify(lastUser.content).includes('do X instead'), 'steering text preserved as user input');
66
+ const prev = msgs[msgs.length - 2];
67
+ assert.ok(
68
+ Array.isArray(prev.content) && prev.content.some((b) => b.type === 'tool_result'),
69
+ 'tool_result stays on its own preceding turn',
70
+ );
71
+ });
@@ -1,23 +1,27 @@
1
1
  import test from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
3
  import * as crypto from 'node:crypto';
4
- import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
4
+ import { mkdtempSync } from 'node:fs';
5
5
  import { tmpdir } from 'node:os';
6
6
  import { join } from 'node:path';
7
7
  import { readMarkdownDocument } from '../src/runtime/shared/markdown-frontmatter.mjs';
8
8
 
9
9
  // DATA_DIR resolves from MIXDOG_DATA_DIR at module import time (via
10
10
  // ./config.mjs -> resolvePluginData), so the env var MUST be set before
11
- // the first dynamic import of webhook.mjs.
11
+ // the first dynamic import of webhook.mjs. Endpoint defs + delivery dedup now
12
+ // live in PG; this smoke suite deliberately exercises ONLY the pure,
13
+ // PG-free surface (signature verify + header helpers) so it needs no live DB.
12
14
  const dataDir = mkdtempSync(join(tmpdir(), 'mixdog-webhook-test-'));
13
15
  process.env.MIXDOG_DATA_DIR = dataDir;
14
16
 
15
17
  const {
16
18
  extractSignature,
17
19
  verifySignature,
18
- loadEndpointConfig,
19
20
  STRIPE_TOLERANCE_MS,
20
21
  } = await import('../src/runtime/channels/lib/webhook.mjs');
22
+ const { extractDeliveryId, buildHeadersSummary } = await import(
23
+ '../src/runtime/channels/lib/webhook/deliveries.mjs'
24
+ );
21
25
 
22
26
  function hmacHex(secret, payload) {
23
27
  return crypto.createHmac('sha256', secret).update(payload).digest('hex');
@@ -141,56 +145,45 @@ test('extractSignature: returns null when no known signature header is present',
141
145
  assert.equal(extractSignature(headers, 'github'), null);
142
146
  });
143
147
 
144
- // ── endpoint config loader: WEBHOOK.md frontmatter + secret side file ─
145
-
146
- test('loadEndpointConfig: loads frontmatter + reads secret from side file', () => {
147
- const name = 'my-endpoint';
148
- const dir = join(dataDir, 'webhooks', name);
149
- mkdirSync(dir, { recursive: true });
150
- writeFileSync(
151
- join(dir, 'WEBHOOK.md'),
152
- [
153
- '---',
154
- 'channel: main',
155
- 'parser: github',
156
- 'enabled: true',
157
- '---',
158
- '',
159
- 'Handle this webhook by summarizing the payload.',
160
- '',
161
- ].join('\n'),
162
- 'utf8',
163
- );
164
- writeFileSync(join(dir, 'secret'), 'my-webhook-secret\n', 'utf8');
165
-
166
- const cfg = loadEndpointConfig(name);
167
- assert.equal(cfg.channel, 'main');
168
- assert.equal(cfg.parser, 'github');
169
- assert.equal(cfg.enabled, true);
170
- });
171
-
172
- test('loadEndpointConfig: enabled: false frontmatter casts to boolean false', () => {
173
- const name = 'disabled-endpoint';
174
- const dir = join(dataDir, 'webhooks', name);
175
- mkdirSync(dir, { recursive: true });
176
- writeFileSync(
177
- join(dir, 'WEBHOOK.md'),
178
- ['---', 'channel: main', 'enabled: false', '---', '', 'body text', ''].join('\n'),
179
- 'utf8',
180
- );
181
- const cfg = loadEndpointConfig(name);
182
- assert.equal(cfg.enabled, false);
183
- });
184
-
185
- test('loadEndpointConfig: returns null for a name with no WEBHOOK.md', () => {
186
- assert.equal(loadEndpointConfig('never-registered'), null);
187
- });
188
-
189
- // loadEndpointConfig only surfaces the frontmatter (by design — see
190
- // webhook.mjs comment above loadEndpointConfig); confirm the underlying
191
- // shared parser it delegates to also recovers the markdown body, since
192
- // the loader's frontmatter/body split is not independently exercised
193
- // through the exported loadEndpointConfig surface.
148
+ // ── delivery-id extraction: header precedence + null fallback ─────────
149
+
150
+ test('extractDeliveryId: prefers x-github-delivery over other id headers', () => {
151
+ const headers = { 'x-github-delivery': 'gh-1', 'x-delivery-id': 'dl-1', 'x-request-id': 'rq-1' };
152
+ assert.equal(extractDeliveryId(headers), 'gh-1');
153
+ });
154
+
155
+ test('extractDeliveryId: falls back to x-delivery-id then x-request-id', () => {
156
+ assert.equal(extractDeliveryId({ 'x-delivery-id': 'dl-1', 'x-request-id': 'rq-1' }), 'dl-1');
157
+ assert.equal(extractDeliveryId({ 'x-request-id': 'rq-1' }), 'rq-1');
158
+ });
159
+
160
+ test('extractDeliveryId: returns null when no id header is present', () => {
161
+ assert.equal(extractDeliveryId({ 'content-type': 'application/json' }), null);
162
+ });
163
+
164
+ // ── headers summary: event/delivery/content-type + signature presence ─
165
+
166
+ test('buildHeadersSummary: captures event, delivery, content-type and signature presence', () => {
167
+ const summary = buildHeadersSummary({
168
+ 'x-github-event': 'issues',
169
+ 'x-github-delivery': 'gh-1',
170
+ 'x-hub-signature-256': 'sha256=abc',
171
+ 'content-type': 'application/json',
172
+ });
173
+ assert.equal(summary.event_type, 'issues');
174
+ assert.equal(summary.delivery_id, 'gh-1');
175
+ assert.equal(summary.signature_present, true);
176
+ assert.equal(summary.content_type, 'application/json');
177
+ });
178
+
179
+ test('buildHeadersSummary: signature_present is false when no signature header is present', () => {
180
+ const summary = buildHeadersSummary({ 'x-github-event': 'push' });
181
+ assert.equal(summary.signature_present, false);
182
+ assert.equal(summary.event_type, 'push');
183
+ });
184
+
185
+ // The webhook body/instructions still round-trip through the shared markdown
186
+ // parser (frontmatter + body split); confirm that pure surface directly.
194
187
  test('readMarkdownDocument: WEBHOOK.md-shaped input yields frontmatter + body', () => {
195
188
  const raw = [
196
189
  '---',
@@ -261,7 +261,12 @@ TUI 피커 없음 (settings-api `setSystemShell`만 존재).
261
261
  ### 스케줄 / 웹훅 ("스케줄 추가", "웹훅")
262
262
 
263
263
  1. **확인**: `/schedules` / `/webhooks` (둘 다 `/channels` 허브의 섹션 딥링크).
264
- 2. **변경**: 해당 피커에서 추가/편집 — 채널 모듈 설정에 저장.
264
+ 2. **변경**: 해당 피커에서 추가/편집.
265
+ - **스케줄 저장소 = PG 테이블 `scheduler.schedules`** (더 이상 `<mixdogData>/schedules/<n>/SCHEDULE.md` 파일 아님). 관리 API `saveSchedule/deleteSchedule/setScheduleEnabled/listSchedules` (`channel-admin.mjs`) → 스토어 `schedules-db.mjs`.
266
+ - **필드**: 반복형은 `time`(5·6필드 cron) + 선택 `days`(cron 요일필드로 접힘: daily→`*`, weekday→`1-5`, weekend→`0,6`, `mon,wed,fri`/`1,3,5`→숫자; 못 매핑하면 에러) → `when_cron`. 1회성은 `at`(datetime) → `when_at`. `time`·`at`은 **택1**(스토어 XOR). `channel` 지정 시 `target=channel`+`channel_id`(이때 `model` 필수), 없으면 `target=session`. 본문(instructions)=`prompt`.
267
+ - **레거시 마이그레이션**: 기존 `schedules/` 디렉터리는 스토어 첫 init 시 SCHEDULE.md들을 테이블로 1회 자동 임포트(같은 days→cron 접힘) 후 디렉터리를 `schedules.migrated`로 rename(삭제 안 함). 한 줄 요약 로그.
268
+ - **웹훅 저장소 = PG 테이블 `webhooks.endpoints`** (더 이상 `<mixdogData>/webhooks/<n>/WEBHOOK.md` + `secret` 파일 아님). 관리 API `saveWebhook/deleteWebhook/setWebhookEnabled/listWebhooks` (`channel-admin.mjs`) → 스토어 `webhooks-db.mjs`(`upsertEndpoint/deleteEndpoint/setEndpointEnabled/listEndpoints`). 필드: `parser`(github·generic·stripe·sentry), 선택 `channel`(지정 시 `model` 필수)→`channel_id`, `secret`(미지정 시 랜덤 생성, 컬럼에 저장), 본문(instructions), `enabled`.
269
+ - **웹훅 레거시 마이그레이션**: 기존 `webhooks/` 디렉터리는 스토어 첫 init 시 각 `WEBHOOK.md`+`secret`을 `upsertEndpoint`로 1회 자동 임포트 후 디렉터리를 **삭제**(rename 아님 — 사용자 선택). 부분 실패 시 디렉터리 보존·로그·다음 부팅 재시도. 옛 per-endpoint deliveries(중복제거 이력)는 임포트 안 함(리셋 허용).
265
270
  3. **검증**: 피커 목록 반영; 스케줄은 다음 발동 시각 표시.
266
271
 
267
272
  ### 원격 세션 ("리모트 가져와")
@@ -1,8 +1,10 @@
1
1
  # Tool Use
2
2
 
3
- - Batch independent calls in one turn; serialize only when a call needs a
4
- prior result. Merge equivalent variants/scopes into ONE call wherever the
5
- schema accepts arrays (`pattern[]`, `path[]`, `symbols[]`, `query[]`).
3
+ - Every turn carries ALL independent calls you can already specify a
4
+ second consecutive single-lookup turn is a defect, not a style choice;
5
+ serialize only on real data dependency. Merge variants/scopes into ONE
6
+ call wherever the schema accepts arrays (`pattern[]`, `path[]`,
7
+ `symbols[]`, `query[]`).
6
8
  - Route by what is already known: known symbol/relation → `code_graph`;
7
9
  exact text in a known scope → `grep`; unknown location, machine-wide/
8
10
  out-of-repo whereabouts, or concept-level question → `explore` (which uses
@@ -31,4 +33,6 @@
31
33
  unit (function/section) — over-read once instead of paging the same file
32
34
  in small windows across turns; a 3rd window into one file means the first
33
35
  should have been wider.
34
- - Don't mix `apply_patch` with shell or other state-changing calls in one turn.
36
+ - Don't mix `apply_patch` with shell or other state-changing calls in one
37
+ turn; batch independent-file patches in one turn, then verify them all in
38
+ ONE shell call the next.
@@ -3,6 +3,7 @@ import { appendFile } from 'fs/promises';
3
3
  import { dirname, join } from 'path';
4
4
  import os from 'os';
5
5
  import { getPluginData } from './config.mjs';
6
+ import { readServicePort, markServiceUnreachable, isConnRefuseError } from '../../shared/service-discovery.mjs';
6
7
 
7
8
  const WARNED_KEYS = new Set();
8
9
 
@@ -15,6 +16,10 @@ const _FLUSH_INTERVAL_MS = 5000;
15
16
  const _FLUSH_BATCH_SIZE = 500;
16
17
  let _flushTimer = null;
17
18
  let _serviceUrl = null;
19
+ // Port of the discovery advert `_serviceUrl` was resolved from (null when it
20
+ // came from legacy active-instance.json). Lets a flush connect-failure distrust
21
+ // a recycled-pid corpse advert so the next resolve falls back to legacy/buffer.
22
+ let _serviceAdvertPort = null;
18
23
  let _flushInFlight = false;
19
24
  let _localTracePath = null;
20
25
  let _localTraceBuffer = [];
@@ -197,6 +202,11 @@ try {
197
202
  function _resolveServiceUrl() {
198
203
  if (_serviceUrl) return _serviceUrl;
199
204
  try {
205
+ // Prefer the single-writer discovery advert (discovery/memory.json),
206
+ // pid-validated; fall back to legacy active-instance.json memory_port.
207
+ const advertPort = readServicePort('memory', { requirePid: false });
208
+ if (advertPort) { _serviceAdvertPort = advertPort; _serviceUrl = `http://127.0.0.1:${advertPort}`; return _serviceUrl; }
209
+ _serviceAdvertPort = null;
200
210
  const runtimeRoot = process.env.MIXDOG_RUNTIME_ROOT
201
211
  ? join(process.env.MIXDOG_RUNTIME_ROOT)
202
212
  : join(os.tmpdir(), 'mixdog');
@@ -245,6 +255,13 @@ async function _flush() {
245
255
  }
246
256
  } catch (err) {
247
257
  _serviceUrl = null;
258
+ // Discovery-first consumer with no health probe: a connect failure
259
+ // means the pid-validated advert points at a dead (recycled-pid)
260
+ // port. Distrust it (connection-level errors ONLY — a timeout is a
261
+ // slow-but-alive daemon, not a corpse) so the next resolve falls back
262
+ // to legacy/buffer instead of re-trusting the same advert.
263
+ if (_serviceAdvertPort && isConnRefuseError(err)) markServiceUnreachable('memory', _serviceAdvertPort);
264
+ _serviceAdvertPort = null;
248
265
  warnAgentOnce('agent-trace:flush-fetch', `[agent-trace] flush fetch failed (${err?.message}) — dropping batch`);
249
266
  }
250
267
  if (_buffer.length >= _FLUSH_BATCH_SIZE) {
@@ -9,6 +9,7 @@
9
9
  // tree (taskkill /T /F on Windows, SIGTERM->SIGKILL of every descendant on
10
10
  // POSIX). No external dependencies.
11
11
  import { spawn } from 'node:child_process';
12
+ import { detachedSpawnOpts } from '../../../shared/spawn-flags.mjs';
12
13
 
13
14
  const isWin = process.platform === 'win32';
14
15
 
@@ -107,9 +108,8 @@ export function killStdioChildTreeFast(transport) {
107
108
  if (isWin) {
108
109
  try {
109
110
  const cp = spawn('taskkill', ['/PID', String(pid), '/T', '/F'], {
110
- windowsHide: true,
111
- detached: true,
112
111
  stdio: 'ignore',
112
+ ...detachedSpawnOpts,
113
113
  });
114
114
  cp.unref();
115
115
  } catch { /* ignore */ }
@@ -5,6 +5,7 @@ import { tmpdir } from 'os';
5
5
  import { randomUUID } from 'crypto';
6
6
  import { smartReadTruncate } from '../tools/builtin/read-formatting.mjs';
7
7
  import { shutdownStdioChild, killStdioChildTreeFast } from './child-tree.mjs';
8
+ import { readServicePort, markServiceUnreachable, isConnRefuseError } from '../../../shared/service-discovery.mjs';
8
9
  // --- Types ---
9
10
  /** Known auto-detect targets: port file path relative to tmpdir.
10
11
  * Note: `mixdog` used to self-loopback via active-instance.json's
@@ -13,7 +14,7 @@ import { shutdownStdioChild, killStdioChildTreeFast } from './child-tree.mjs';
13
14
  * in-process through agent's toolExecutor (see orchestrator/internal-tools),
14
15
  * so this registry is for genuinely external port-based MCP targets only. */
15
16
  const AUTO_DETECT_PORTS = {
16
- 'mixdog-memory': { dir: 'mixdog', file: 'active-instance.json', portField: 'memory_port', endpoint: '/mcp' },
17
+ 'mixdog-memory': { discovery: 'memory', dir: 'mixdog', file: 'active-instance.json', portField: 'memory_port', endpoint: '/mcp' },
17
18
  };
18
19
  const DEFAULT_MCP_CALL_TIMEOUT_MS = 0;
19
20
  // Per-server STARTUP handshake budget (connect + listTools). Codex parity: 10s.
@@ -452,20 +453,31 @@ async function connectServer(name, cfg, genAtStart = _connectAbortGeneration) {
452
453
  const client = new Client({ name: `mixdog-agent/${name}`, version: '1.0.0' });
453
454
  let transport;
454
455
  const kind = resolveMcpTransportKind(cfg);
456
+ // When the autoDetect port comes from a discovery advert (not the legacy
457
+ // port file), remember { service, port } so a connect/handshake failure can
458
+ // distrust it — a pid-live advert can point at a recycled-pid corpse port,
459
+ // and this transport has no other health probe of its own.
460
+ let _autoDetectAdvert = null;
455
461
  // Auto-detect: read port from a running service's port file
456
462
  if (kind === 'autoDetect') {
457
463
  const spec = AUTO_DETECT_PORTS[cfg.autoDetect];
458
464
  if (!spec)
459
465
  throw new Error(`Unknown autoDetect target: "${cfg.autoDetect}"`);
460
- const portFile = spec.dir === 'mixdog' && process.env.MIXDOG_RUNTIME_ROOT
466
+ // Prefer the single-writer discovery advert (discovery/<service>.json),
467
+ // pid-validated. Fall back to the legacy active-instance.json portField
468
+ // when no live advert is present (cross-version compat).
469
+ let port = spec.discovery ? readServicePort(spec.discovery, { requirePid: false }) : null;
470
+ if (port && spec.discovery) _autoDetectAdvert = { service: spec.discovery, port };
471
+ let portFile = null;
472
+ if (!port) {
473
+ portFile = spec.dir === 'mixdog' && process.env.MIXDOG_RUNTIME_ROOT
461
474
  ? join(process.env.MIXDOG_RUNTIME_ROOT, spec.file)
462
475
  : join(tmpdir(), spec.dir, spec.file);
463
- if (!existsSync(portFile)) {
476
+ if (!existsSync(portFile)) {
464
477
  throw new Error(`autoDetect server "${name}": port file missing (${portFile})`);
465
- }
466
- let port;
467
- const raw = readFileSync(portFile, 'utf-8').trim();
468
- if (spec.portField) {
478
+ }
479
+ const raw = readFileSync(portFile, 'utf-8').trim();
480
+ if (spec.portField) {
469
481
  try {
470
482
  const json = JSON.parse(raw);
471
483
  const v = json[spec.portField];
@@ -477,12 +489,13 @@ async function connectServer(name, cfg, genAtStart = _connectAbortGeneration) {
477
489
  if (jsonErr instanceof Error && jsonErr.message.startsWith('autoDetect server')) throw jsonErr;
478
490
  throw new Error(`autoDetect server "${name}": invalid JSON in port file ${portFile}`);
479
491
  }
480
- }
481
- else {
492
+ }
493
+ else {
482
494
  port = parseInt(raw, 10);
495
+ }
483
496
  }
484
497
  if (!Number.isFinite(port) || port < 1 || port > 65535) {
485
- throw new Error(`autoDetect server "${name}": invalid port value in ${portFile}`);
498
+ throw new Error(`autoDetect server "${name}": invalid port value${portFile ? ` in ${portFile}` : ''}`);
486
499
  }
487
500
  const url = `http://127.0.0.1:${port}${spec.endpoint}`;
488
501
  transport = new StreamableHTTPClientTransport(new URL(url));
@@ -568,6 +581,11 @@ async function connectServer(name, cfg, genAtStart = _connectAbortGeneration) {
568
581
  ({ instructions, toolsResult } = await handshake);
569
582
  }
570
583
  } catch (err) {
584
+ // A discovery-advert port that fails to connect is a corpse (recycled
585
+ // pid): distrust it so the next connect falls back to the legacy port
586
+ // file instead of re-trusting the same advert. Connection-level errors
587
+ // ONLY — a startup/handshake timeout is a slow-but-alive server.
588
+ if (_autoDetectAdvert && isConnRefuseError(err)) markServiceUnreachable(_autoDetectAdvert.service, _autoDetectAdvert.port);
571
589
  if (startupTimedOut) {
572
590
  // Tear down the pending transport/child so a hung handshake never
573
591
  // leaks a stdio process or socket — fire-and-forget (like the
@@ -396,7 +396,14 @@ function toAnthropicMessages(messages) {
396
396
  // turn after tool_result renders as `</function_results>\n\nHuman:`
397
397
  // on the wire and trains the model toward 3-token empty end_turn
398
398
  // completions (see foldUserTextIntoToolResultTail).
399
- if (m.role === 'user' && foldUserTextIntoToolResultTail(result, normalizeContentForAnthropic(m.content))) {
399
+ // EXCEPTION: steering-origin user messages (human/TUI interjections)
400
+ // must keep their own user turn so their provenance survives — folding
401
+ // them into the preceding tool_result would disguise user input as
402
+ // tool output. Anthropic accepts a user text message after a
403
+ // tool_result message, so the distinct turn stays request-valid.
404
+ const isSteering = m.role === 'user' && m.meta?.source === 'steering';
405
+ if (m.role === 'user' && !isSteering
406
+ && foldUserTextIntoToolResultTail(result, normalizeContentForAnthropic(m.content))) {
400
407
  continue;
401
408
  }
402
409
  result.push({ role: m.role, content: normalizeContentForAnthropic(m.content) });
@@ -397,7 +397,13 @@ function toAnthropicMessages(messages) {
397
397
  // First-party client parity: fold a user text turn that directly follows a
398
398
  // tool_result turn into that tool_result's content (empty end_turn
399
399
  // livelock prevention; see foldUserTextIntoToolResultTail).
400
- if (m.role === 'user' && foldUserTextIntoToolResultTail(result, normalizeContentForAnthropic(m.content))) {
400
+ // EXCEPTION: steering-origin user messages (human/TUI interjections)
401
+ // keep their own user turn so provenance survives — folding them would
402
+ // disguise user input as tool output. Anthropic accepts a user text
403
+ // message after a tool_result message, so the turn stays request-valid.
404
+ const isSteering = m.role === 'user' && m.meta?.source === 'steering';
405
+ if (m.role === 'user' && !isSteering
406
+ && foldUserTextIntoToolResultTail(result, normalizeContentForAnthropic(m.content))) {
401
407
  continue;
402
408
  }
403
409
  result.push({ role: m.role, content: normalizeContentForAnthropic(m.content) });
@@ -405,6 +411,13 @@ function toAnthropicMessages(messages) {
405
411
  return sanitizeAnthropicContentPairs(result);
406
412
  }
407
413
 
414
+ // Test-only: expose the lowering so the steering-provenance test can assert
415
+ // the API-key provider keeps steering-tagged user turns distinct (mirrors
416
+ // anthropic-oauth._buildRequestBodyForCacheSmoke coverage).
417
+ export function _toAnthropicMessagesForTest(messages) {
418
+ return toAnthropicMessages(messages);
419
+ }
420
+
408
421
  // Applies cache_control markers to the FINAL, already-sanitized Anthropic
409
422
  // message array — by INVARIANT, never by pre-sanitize index. Because
410
423
  // sanitizeAnthropicContentPairs has already run (and must NOT run again
@@ -242,10 +242,15 @@ function windowFromPercent(label, value, source) {
242
242
  }
243
243
 
244
244
  // Grok Build billing periods are migrating from monthly to a shared weekly
245
- // pool (xAI rollout is per-account). Derive the window cadence from the
246
- // actual billing period length instead of hardcoding monthly: <=10 days
247
- // means a weekly cycle, anything longer (or unknown) stays monthly.
245
+ // pool (xAI rollout is per-account). Unified-billing accounts state the
246
+ // cadence explicitly via currentPeriod.type (USAGE_PERIOD_TYPE_WEEKLY);
247
+ // otherwise derive it from the actual billing period length instead of
248
+ // hardcoding monthly: <=10 days means a weekly cycle, anything longer (or
249
+ // unknown) stays monthly.
248
250
  function grokBillingCadence(config) {
251
+ const periodType = cleanString(config?.currentPeriod?.type ?? config?.current_period?.type ?? '').toUpperCase();
252
+ if (periodType.includes('WEEKLY')) return { label: 'W', period: 'weekly' };
253
+ if (periodType.includes('MONTHLY')) return { label: 'M', period: 'monthly' };
249
254
  if (config?.weeklyLimit !== undefined || config?.weekly_limit !== undefined) {
250
255
  return { label: 'W', period: 'weekly' };
251
256
  }
@@ -259,6 +264,11 @@ function grokBillingCadence(config) {
259
264
 
260
265
  function creditWindowFromBilling(config) {
261
266
  if (!config || typeof config !== 'object') return null;
267
+ const cadence = grokBillingCadence(config);
268
+ const resetAt = resetAtMs(
269
+ config.currentPeriod?.end ?? config.current_period?.end
270
+ ?? config.billingPeriodEnd ?? config.billing_period_end,
271
+ );
262
272
  const limit = num(
263
273
  config.weeklyLimit?.val ?? config.weeklyLimit
264
274
  ?? config.monthlyLimit?.val ?? config.monthlyLimit
@@ -266,10 +276,27 @@ function creditWindowFromBilling(config) {
266
276
  null,
267
277
  );
268
278
  const used = num(config.used?.val ?? config.includedUsed?.val ?? config.used, null);
269
- if (limit === null || used === null || !(limit > 0)) return null;
270
- const resetAt = resetAtMs(config.billingPeriodEnd ?? config.billing_period_end);
279
+ if (limit === null || used === null || !(limit > 0)) {
280
+ // Unified-billing accounts (shared weekly pool) report utilization as a
281
+ // percentage instead of credit totals; absent means 0% used.
282
+ const unified = config.isUnifiedBillingUser === true
283
+ || config.is_unified_billing_user === true
284
+ || config.currentPeriod || config.current_period;
285
+ if (!unified) return null;
286
+ const usedPct = num(
287
+ config.creditUsagePercent?.val ?? config.creditUsagePercent
288
+ ?? config.credit_usage_percent,
289
+ 0,
290
+ );
291
+ return {
292
+ label: cadence.label,
293
+ source: 'grok-build-billing',
294
+ usedPct: round(Math.min(100, Math.max(0, usedPct)), 2),
295
+ ...(resetAt ? { resetAt } : {}),
296
+ };
297
+ }
271
298
  return {
272
- label: grokBillingCadence(config).label,
299
+ label: cadence.label,
273
300
  source: 'grok-build-billing',
274
301
  usedPct: round(Math.min(100, used * 100 / limit), 2),
275
302
  usedCredits: round(used, 2),
@@ -495,29 +522,38 @@ async function fetchGrokUsage(providerObj, routeInfo) {
495
522
  Authorization: `Bearer ${token}`,
496
523
  'X-XAI-Token-Auth': 'xai-grok-cli',
497
524
  'x-userid': userId,
525
+ 'x-grok-client-version': '0.2.87',
498
526
  Accept: 'application/json',
499
- 'User-Agent': 'xai-grok-build/0.2.16',
527
+ 'User-Agent': 'xai-grok-build/0.2.87',
500
528
  };
501
529
 
502
- try {
503
- const res = await fetch('https://cli-chat-proxy.grok.com/v1/billing', fetchOptions(cliHeaders));
504
- if (res.ok) {
530
+ // format=credits is what the official grok CLI requests; unified-billing
531
+ // (shared weekly pool) accounts only report their real cadence and reset
532
+ // there. The legacy /v1/billing shape keeps serving a stale monthly cycle
533
+ // for migrated accounts, so it is only a fallback.
534
+ for (const url of [
535
+ 'https://cli-chat-proxy.grok.com/v1/billing?format=credits',
536
+ 'https://cli-chat-proxy.grok.com/v1/billing',
537
+ ]) {
538
+ try {
539
+ const res = await fetch(url, fetchOptions(cliHeaders));
540
+ if (!res.ok) continue;
505
541
  const data = await res.json();
506
542
  const config = data?.config && typeof data.config === 'object' ? data.config : data;
507
- const monthly = creditWindowFromBilling(config);
508
- if (monthly) {
543
+ const window = creditWindowFromBilling(config);
544
+ if (window) {
509
545
  return {
510
546
  provider: routeInfo?.provider || 'grok-oauth',
511
547
  model: routeInfo?.model || null,
512
548
  source: 'grok-build-billing',
513
- quotaWindows: [monthly],
549
+ quotaWindows: [window],
514
550
  balance: balanceFromGrokBilling(config),
515
551
  rawKeys: Object.keys(data || {}).sort(),
516
552
  };
517
553
  }
554
+ } catch {
555
+ // Fall through to the next candidate / generic probes below.
518
556
  }
519
- } catch {
520
- // Fall through to generic probes below.
521
557
  }
522
558
 
523
559
  // xAI documents per-request cost tracking and console rate-limit pages, but
@@ -202,7 +202,11 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
202
202
  if (typeof options.beforeAppend === 'function') {
203
203
  try { options.beforeAppend(); } catch { /* best-effort hook */ }
204
204
  }
205
- messages.push({ role: 'user', content: merged.content });
205
+ // Tag steering-origin user messages so provider lowering keeps them a
206
+ // distinct user turn (see anthropic-oauth foldUserTextIntoToolResultTail):
207
+ // human/TUI steering must stay attributed as user input, never folded
208
+ // into a preceding tool_result where it reads as tool output.
209
+ messages.push({ role: 'user', content: merged.content, meta: { source: 'steering' } });
206
210
  try { opts.onSteerMessage?.(merged.text || steeringContentText(merged.content)); } catch {}
207
211
  if (sessionId) {
208
212
  try { process.stderr.write(`[steer] sess=${sessionId} injected ${stage} user message (merged=${merged.count} len=${String(merged.text || '').length})\n`); } catch {}