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.
- package/package.json +1 -1
- package/scripts/steering-fold-provenance-test.mjs +71 -0
- package/scripts/webhook-smoke.mjs +46 -53
- package/src/defaults/skills/setup/SKILL.md +6 -1
- package/src/rules/shared/01-tool.md +8 -4
- package/src/runtime/agent/orchestrator/agent-trace-io.mjs +17 -0
- package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +2 -2
- package/src/runtime/agent/orchestrator/mcp/client.mjs +28 -10
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +8 -1
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +14 -1
- package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +51 -15
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +5 -1
- package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +164 -9
- package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +45 -0
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +4 -0
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +12 -1
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/shell-state.mjs +7 -3
- package/src/runtime/channels/lib/config.mjs +13 -11
- package/src/runtime/channels/lib/memory-client.mjs +22 -2
- package/src/runtime/channels/lib/owned-runtime.mjs +1 -1
- package/src/runtime/channels/lib/scheduler.mjs +226 -208
- package/src/runtime/channels/lib/status-snapshot.mjs +16 -0
- package/src/runtime/channels/lib/webhook/deliveries.mjs +7 -318
- package/src/runtime/channels/lib/webhook.mjs +98 -150
- package/src/runtime/channels/lib/worker-main.mjs +1 -1
- package/src/runtime/memory/index.mjs +50 -25
- package/src/runtime/memory/lib/cycle-scheduler.mjs +3 -1
- package/src/runtime/memory/lib/memory-config-flags.mjs +13 -1
- package/src/runtime/memory/lib/pg/adapter.mjs +6 -1
- package/src/runtime/memory/lib/pg/process.mjs +19 -1
- package/src/runtime/memory/lib/pg/supervisor.mjs +125 -14
- package/src/runtime/memory/lib/query-handlers.mjs +102 -0
- package/src/runtime/memory/lib/recall-format.mjs +60 -22
- package/src/runtime/memory/lib/session-ingest-runtime.mjs +20 -6
- package/src/runtime/memory/tool-defs.mjs +1 -1
- package/src/runtime/shared/child-guardian.mjs +2 -2
- package/src/runtime/shared/open-url.mjs +2 -1
- package/src/runtime/shared/schedules-db.mjs +350 -0
- package/src/runtime/shared/service-discovery.mjs +169 -0
- package/src/runtime/shared/spawn-flags.mjs +27 -0
- package/src/runtime/shared/tool-primitives.mjs +3 -1
- package/src/runtime/shared/tool-surface.mjs +19 -3
- package/src/runtime/shared/update-checker.mjs +54 -10
- package/src/runtime/shared/webhooks-db.mjs +405 -0
- package/src/session-runtime/channel-config-api.mjs +13 -13
- package/src/session-runtime/lifecycle-api.mjs +14 -0
- package/src/session-runtime/runtime-core.mjs +39 -20
- package/src/standalone/agent-tool.mjs +42 -11
- package/src/standalone/channel-admin.mjs +173 -121
- package/src/standalone/channel-worker.mjs +2 -2
- package/src/standalone/memory-runtime-proxy.mjs +10 -5
- package/src/tui/App.jsx +32 -10
- package/src/tui/app/channel-pickers.mjs +9 -9
- package/src/tui/app/clipboard.mjs +14 -0
- package/src/tui/app/doctor.mjs +1 -1
- package/src/tui/app/maintenance-pickers.mjs +17 -7
- package/src/tui/app/settings-picker.mjs +2 -2
- package/src/tui/app/transcript-window.mjs +37 -1
- package/src/tui/app/use-mouse-input.mjs +19 -6
- package/src/tui/components/ToolExecution.jsx +12 -4
- package/src/tui/display-width.mjs +10 -4
- package/src/tui/dist/index.mjs +129 -55
- package/src/tui/engine/session-api-ext.mjs +12 -12
- package/src/tui/index.jsx +12 -2
- package/src/ui/statusline-segments.mjs +12 -1
- package/vendor/ink/build/display-width.js +5 -4
- package/src/runtime/shared/schedules-store.mjs +0 -82
package/package.json
CHANGED
|
@@ -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 {
|
|
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
|
-
// ──
|
|
145
|
-
|
|
146
|
-
test('
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
assert.equal(
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
476
|
+
if (!existsSync(portFile)) {
|
|
464
477
|
throw new Error(`autoDetect server "${name}": port file missing (${portFile})`);
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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).
|
|
246
|
-
//
|
|
247
|
-
//
|
|
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))
|
|
270
|
-
|
|
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:
|
|
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.
|
|
527
|
+
'User-Agent': 'xai-grok-build/0.2.87',
|
|
500
528
|
};
|
|
501
529
|
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
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
|
|
508
|
-
if (
|
|
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: [
|
|
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
|
-
|
|
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 {}
|