@pugi/cli 0.1.0-beta.12 → 0.1.0-beta.14
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/dist/core/consensus/diff-capture.js +73 -0
- package/dist/core/context/index.js +7 -0
- package/dist/core/context/markdown-traverse.js +255 -0
- package/dist/core/edits/dispatch.js +218 -2
- package/dist/core/edits/journal.js +199 -0
- package/dist/core/edits/layer-d-ast.js +557 -14
- package/dist/core/edits/verify-hook.js +273 -0
- package/dist/core/engine/anvil-client.js +99 -5
- package/dist/core/engine/context-prefix.js +155 -0
- package/dist/core/engine/intent.js +260 -0
- package/dist/core/engine/native-pugi.js +663 -249
- package/dist/core/engine/prompts.js +52 -2
- package/dist/core/engine/tool-bridge.js +311 -9
- package/dist/core/lsp/client.js +57 -0
- package/dist/core/mcp/client.js +9 -0
- package/dist/core/mcp/http-server.js +553 -0
- package/dist/core/mcp/permission.js +190 -0
- package/dist/core/mcp/server-tools.js +219 -0
- package/dist/core/mcp/server.js +397 -0
- package/dist/core/repl/history.js +11 -1
- package/dist/core/repl/model-pricing.js +135 -0
- package/dist/core/repl/session.js +328 -12
- package/dist/core/repl/slash-commands.js +18 -4
- package/dist/core/settings.js +43 -0
- package/dist/core/subagents/dispatcher-real.js +600 -0
- package/dist/core/subagents/dispatcher.js +113 -24
- package/dist/core/subagents/index.js +18 -5
- package/dist/core/subagents/isolation-matrix.js +213 -0
- package/dist/core/subagents/spawn.js +19 -4
- package/dist/core/transport/version-interceptor.js +166 -0
- package/dist/index.js +28 -0
- package/dist/runtime/bootstrap.js +190 -0
- package/dist/runtime/cli.js +859 -269
- package/dist/runtime/commands/lsp.js +165 -5
- package/dist/runtime/commands/mcp.js +537 -0
- package/dist/runtime/commands/review-consensus.js +17 -2
- package/dist/runtime/headless.js +543 -0
- package/dist/runtime/load-hooks-or-exit.js +71 -0
- package/dist/runtime/version.js +65 -0
- package/dist/tools/agent-tool.js +192 -0
- package/dist/tools/apply-patch.js +62 -1
- package/dist/tools/mcp-tool.js +260 -0
- package/dist/tools/multi-edit.js +361 -0
- package/dist/tools/registry.js +5 -0
- package/dist/tools/web-fetch.js +147 -2
- package/dist/tools/web-search.js +458 -0
- package/dist/tui/agent-tree.js +10 -0
- package/dist/tui/ask-modal.js +2 -2
- package/dist/tui/conversation-pane.js +1 -1
- package/dist/tui/input-box.js +1 -1
- package/dist/tui/markdown-render.js +4 -4
- package/dist/tui/repl-render.js +105 -15
- package/dist/tui/repl-splash.js +2 -2
- package/dist/tui/repl.js +10 -4
- package/dist/tui/splash.js +1 -1
- package/dist/tui/status-bar.js +94 -16
- package/dist/tui/update-banner.js +20 -2
- package/package.json +5 -4
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
/**
|
|
3
|
+
* Pugi MCP server (β4 M2) — exposes Pugi's native tool surface to other
|
|
4
|
+
* agents (Claude Code, OpenCode, Codex CLI, any client that speaks
|
|
5
|
+
* MCP).
|
|
6
|
+
*
|
|
7
|
+
* Transport-agnostic core. The stdio entry-point lives at the bottom of
|
|
8
|
+
* this module (`serveStdio`); the HTTP+SSE wrapper lives in
|
|
9
|
+
* `./http-server.ts` and feeds the same core via the synchronous
|
|
10
|
+
* `handleMessage` entry-point.
|
|
11
|
+
*
|
|
12
|
+
* Spec: https://modelcontextprotocol.io/specification (2024-11-05).
|
|
13
|
+
*
|
|
14
|
+
* Methods implemented:
|
|
15
|
+
* - `initialize` -> protocol handshake, server capabilities
|
|
16
|
+
* - `notifications/initialized` -> ack, no-op
|
|
17
|
+
* - `tools/list` -> Pugi tool schemas
|
|
18
|
+
* - `tools/call` -> dispatch to the underlying executor
|
|
19
|
+
* - `ping` -> liveness, returns `{}` (some MCP clients
|
|
20
|
+
* poll this on a HTTP transport)
|
|
21
|
+
*
|
|
22
|
+
* NOT yet implemented (deferred — these are not on the β4 acceptance
|
|
23
|
+
* surface):
|
|
24
|
+
* - `resources/*` — file resource browser
|
|
25
|
+
* - `prompts/*` — server-supplied prompts
|
|
26
|
+
* - `sampling/*` — agent sampling callbacks
|
|
27
|
+
* - `notifications/cancelled` — client-side cancellation (we honour
|
|
28
|
+
* the AbortSignal passed at construction
|
|
29
|
+
* instead)
|
|
30
|
+
*
|
|
31
|
+
* Why hand-rolled instead of `@modelcontextprotocol/sdk`:
|
|
32
|
+
* - The SDK ships every transport (stdio, HTTP, WebSocket, SSE) and
|
|
33
|
+
* drags in a 3 MB compressed dependency footprint. Pugi-CLI ships as
|
|
34
|
+
* `npm i -g pugi` and every transitive dep is supply-chain risk.
|
|
35
|
+
* - The core JSON-RPC envelope is <500 LOC and we already maintain the
|
|
36
|
+
* client-side variant in `./client.ts` — keeping the server matching
|
|
37
|
+
* hand-roll lets a code reviewer hold both sides of the wire in one
|
|
38
|
+
* head.
|
|
39
|
+
* - Pugi-specific extensions (permission FSM hooks on `tools/call`,
|
|
40
|
+
* bearer-auth attestation on HTTP, scope-limited tool filtering for
|
|
41
|
+
* paired-agent worktrees) drop in cleanly because we own the
|
|
42
|
+
* dispatch table.
|
|
43
|
+
*/
|
|
44
|
+
/* ---------- protocol types (mirror client.ts conventions) ----------------- */
|
|
45
|
+
export const PUGI_MCP_PROTOCOL_VERSION = '2024-11-05';
|
|
46
|
+
export const PUGI_MCP_SERVER_NAME = 'pugi';
|
|
47
|
+
export const PUGI_MCP_SERVER_VERSION = '0.1.0';
|
|
48
|
+
/** JSON-RPC standard error codes used by the server. */
|
|
49
|
+
export const MCP_ERROR_CODES = Object.freeze({
|
|
50
|
+
PARSE_ERROR: -32700,
|
|
51
|
+
INVALID_REQUEST: -32600,
|
|
52
|
+
METHOD_NOT_FOUND: -32601,
|
|
53
|
+
INVALID_PARAMS: -32602,
|
|
54
|
+
INTERNAL_ERROR: -32603,
|
|
55
|
+
/**
|
|
56
|
+
* Pugi-specific: operator-side permission gate refused this tool call.
|
|
57
|
+
* Distinct from generic INTERNAL_ERROR so MCP clients can surface a
|
|
58
|
+
* "permission denied" status to their user.
|
|
59
|
+
*/
|
|
60
|
+
PERMISSION_REFUSED: -32001,
|
|
61
|
+
/**
|
|
62
|
+
* Pugi-specific: HTTP transport saw an invalid or missing bearer
|
|
63
|
+
* token. Stdio transport never emits this code (no auth).
|
|
64
|
+
*/
|
|
65
|
+
AUTH_REQUIRED: -32002,
|
|
66
|
+
});
|
|
67
|
+
export class McpServerToolError extends Error {
|
|
68
|
+
code;
|
|
69
|
+
constructor(message, code = MCP_ERROR_CODES.INTERNAL_ERROR) {
|
|
70
|
+
super(message);
|
|
71
|
+
this.name = 'McpServerToolError';
|
|
72
|
+
this.code = code;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Build a Pugi MCP server bound to a fixed tool surface. Stateless
|
|
77
|
+
* between requests — the same instance can drive many concurrent stdio
|
|
78
|
+
* connections (one per child process) without cross-talk.
|
|
79
|
+
*/
|
|
80
|
+
export function createPugiMcpServer(options) {
|
|
81
|
+
const events = new EventEmitter();
|
|
82
|
+
// Build a lookup by name once at construction. Duplicate names are a
|
|
83
|
+
// programmer error and we throw eagerly so the bug surfaces in the
|
|
84
|
+
// boot path, not at the first `tools/call`.
|
|
85
|
+
const byName = new Map();
|
|
86
|
+
for (const tool of options.tools) {
|
|
87
|
+
// Reject names that would re-encode as MCP `mcp__<server>__<tool>`
|
|
88
|
+
// false-positively on the consumer side — a server name containing
|
|
89
|
+
// `__` makes parseMcpToolName misalign the split. We surface the
|
|
90
|
+
// bug here, not at first dispatch. β4 r1 P2.
|
|
91
|
+
if (tool.name.includes('__')) {
|
|
92
|
+
throw new Error(`pugi mcp server: tool name "${tool.name}" must not contain "__" (collides with mcp__<server>__<tool> naming)`);
|
|
93
|
+
}
|
|
94
|
+
if (byName.has(tool.name)) {
|
|
95
|
+
throw new Error(`pugi mcp server: duplicate tool name "${tool.name}" — every tool in the surface must be unique`);
|
|
96
|
+
}
|
|
97
|
+
byName.set(tool.name, tool);
|
|
98
|
+
}
|
|
99
|
+
const tools = Array.from(byName.values()).sort((a, b) => a.name.localeCompare(b.name));
|
|
100
|
+
const log = options.log ?? (() => { });
|
|
101
|
+
if (typeof options.permissionGate !== 'function') {
|
|
102
|
+
throw new Error('pugi mcp server: options.permissionGate is required (β4 r1 P1 #2 — no implicit allow-all default)');
|
|
103
|
+
}
|
|
104
|
+
const permissionGate = options.permissionGate;
|
|
105
|
+
const requireInitialized = options.requireInitialized !== false;
|
|
106
|
+
let initialized = false;
|
|
107
|
+
async function dispatch(method, params, clientId) {
|
|
108
|
+
switch (method) {
|
|
109
|
+
case 'initialize': {
|
|
110
|
+
// The MCP spec permits multiple `initialize` calls before
|
|
111
|
+
// `notifications/initialized` settles the handshake — we just
|
|
112
|
+
// re-affirm. After init, repeated `initialize` is harmless on
|
|
113
|
+
// our side and lets the client recover from a state desync.
|
|
114
|
+
return {
|
|
115
|
+
protocolVersion: PUGI_MCP_PROTOCOL_VERSION,
|
|
116
|
+
capabilities: {
|
|
117
|
+
tools: { listChanged: false },
|
|
118
|
+
},
|
|
119
|
+
serverInfo: {
|
|
120
|
+
name: PUGI_MCP_SERVER_NAME,
|
|
121
|
+
version: PUGI_MCP_SERVER_VERSION,
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
case 'ping': {
|
|
126
|
+
return {};
|
|
127
|
+
}
|
|
128
|
+
case 'tools/list': {
|
|
129
|
+
return {
|
|
130
|
+
tools: tools.map((tool) => ({
|
|
131
|
+
name: tool.name,
|
|
132
|
+
description: tool.description,
|
|
133
|
+
inputSchema: tool.inputSchema,
|
|
134
|
+
})),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
case 'tools/call': {
|
|
138
|
+
if (options.signal?.aborted) {
|
|
139
|
+
throw new McpServerToolError('server shutting down', MCP_ERROR_CODES.INTERNAL_ERROR);
|
|
140
|
+
}
|
|
141
|
+
// β4 r1 P2 — require `initialize` + `notifications/initialized`
|
|
142
|
+
// handshake first. Without this, an attacker with the bearer
|
|
143
|
+
// token can dispatch a `tools/call` without ever advertising
|
|
144
|
+
// client capabilities; the MCP spec mandates the handshake.
|
|
145
|
+
if (requireInitialized && !initialized) {
|
|
146
|
+
throw new McpServerToolError('tools/call: MCP handshake not complete — send `initialize` + `notifications/initialized` first', MCP_ERROR_CODES.INVALID_REQUEST);
|
|
147
|
+
}
|
|
148
|
+
const p = (params ?? {});
|
|
149
|
+
const rawName = p['name'];
|
|
150
|
+
if (typeof rawName !== 'string') {
|
|
151
|
+
throw new McpServerToolError('tools/call: params.name (string) is required', MCP_ERROR_CODES.INVALID_PARAMS);
|
|
152
|
+
}
|
|
153
|
+
// Trim whitespace-only names so the lookup fails fast with
|
|
154
|
+
// METHOD_NOT_FOUND instead of silently matching an entry that
|
|
155
|
+
// happens to share leading/trailing whitespace. β4 r1 P2.
|
|
156
|
+
const toolName = rawName.trim();
|
|
157
|
+
if (toolName.length === 0) {
|
|
158
|
+
throw new McpServerToolError('tools/call: params.name (string) is required', MCP_ERROR_CODES.INVALID_PARAMS);
|
|
159
|
+
}
|
|
160
|
+
const tool = byName.get(toolName);
|
|
161
|
+
if (!tool) {
|
|
162
|
+
throw new McpServerToolError(`tools/call: tool "${toolName}" is not registered`, MCP_ERROR_CODES.METHOD_NOT_FOUND);
|
|
163
|
+
}
|
|
164
|
+
const rawArgs = p['arguments'];
|
|
165
|
+
let args;
|
|
166
|
+
if (rawArgs === undefined || rawArgs === null) {
|
|
167
|
+
args = {};
|
|
168
|
+
}
|
|
169
|
+
else if (typeof rawArgs === 'object' && !Array.isArray(rawArgs)) {
|
|
170
|
+
args = rawArgs;
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
throw new McpServerToolError('tools/call: params.arguments must be a JSON object', MCP_ERROR_CODES.INVALID_PARAMS);
|
|
174
|
+
}
|
|
175
|
+
const allowed = await permissionGate({
|
|
176
|
+
tool,
|
|
177
|
+
arguments: args,
|
|
178
|
+
...(clientId ? { clientId } : {}),
|
|
179
|
+
});
|
|
180
|
+
if (!allowed) {
|
|
181
|
+
log('warn', `permission refused: ${tool.name}`);
|
|
182
|
+
throw new McpServerToolError(`permission refused by operator: ${tool.name}`, MCP_ERROR_CODES.PERMISSION_REFUSED);
|
|
183
|
+
}
|
|
184
|
+
// Stamp clientId on emitted events so the HTTP transport can
|
|
185
|
+
// route them to the originating SSE listener instead of
|
|
186
|
+
// broadcasting to every bearer-holder (β4 r1 P1 #5).
|
|
187
|
+
events.emit('tool_call', {
|
|
188
|
+
name: tool.name,
|
|
189
|
+
args,
|
|
190
|
+
...(clientId ? { clientId } : {}),
|
|
191
|
+
});
|
|
192
|
+
try {
|
|
193
|
+
const text = await tool.execute(args);
|
|
194
|
+
events.emit('tool_result', {
|
|
195
|
+
name: tool.name,
|
|
196
|
+
ok: true,
|
|
197
|
+
summary: text.slice(0, 200),
|
|
198
|
+
...(clientId ? { clientId } : {}),
|
|
199
|
+
});
|
|
200
|
+
return {
|
|
201
|
+
content: [{ type: 'text', text }],
|
|
202
|
+
isError: false,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
catch (error) {
|
|
206
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
207
|
+
events.emit('tool_result', {
|
|
208
|
+
name: tool.name,
|
|
209
|
+
ok: false,
|
|
210
|
+
summary: message.slice(0, 200),
|
|
211
|
+
...(clientId ? { clientId } : {}),
|
|
212
|
+
});
|
|
213
|
+
// Tool-level failures surface as MCP `isError: true` content —
|
|
214
|
+
// the client knows the call ran but failed, distinct from a
|
|
215
|
+
// protocol-level error (bad tool name, malformed params).
|
|
216
|
+
return {
|
|
217
|
+
content: [{ type: 'text', text: message }],
|
|
218
|
+
isError: true,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
case 'notifications/initialized': {
|
|
223
|
+
initialized = true;
|
|
224
|
+
events.emit('initialized');
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
default: {
|
|
228
|
+
if (method.startsWith('notifications/')) {
|
|
229
|
+
// Silently accept unknown notifications — the spec requires
|
|
230
|
+
// they be ignored, not errored.
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
throw new McpServerToolError(`method not found: ${method}`, MCP_ERROR_CODES.METHOD_NOT_FOUND);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
async function handleMessage(request) {
|
|
238
|
+
const clientId = request.meta?.clientId;
|
|
239
|
+
// Notifications never get a response.
|
|
240
|
+
const isNotification = request.id === undefined || request.id === null;
|
|
241
|
+
if (isNotification) {
|
|
242
|
+
try {
|
|
243
|
+
await dispatch(request.method, request.params, clientId);
|
|
244
|
+
}
|
|
245
|
+
catch (error) {
|
|
246
|
+
log('error', `notification handler threw: ${error.message}`);
|
|
247
|
+
}
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
const id = request.id;
|
|
251
|
+
try {
|
|
252
|
+
const result = await dispatch(request.method, request.params, clientId);
|
|
253
|
+
// dispatch may return null for void responses (notifications) —
|
|
254
|
+
// but we already filtered those above. Treat null here as `{}`.
|
|
255
|
+
return {
|
|
256
|
+
jsonrpc: '2.0',
|
|
257
|
+
id,
|
|
258
|
+
result: result ?? {},
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
catch (error) {
|
|
262
|
+
const isToolError = error instanceof McpServerToolError;
|
|
263
|
+
const code = isToolError ? error.code : MCP_ERROR_CODES.INTERNAL_ERROR;
|
|
264
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
265
|
+
log('error', `dispatch ${request.method} (id=${id}) failed: ${message}`);
|
|
266
|
+
return {
|
|
267
|
+
jsonrpc: '2.0',
|
|
268
|
+
id,
|
|
269
|
+
error: { code, message },
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return {
|
|
274
|
+
handleMessage,
|
|
275
|
+
events,
|
|
276
|
+
listToolsSync() {
|
|
277
|
+
return tools.slice();
|
|
278
|
+
},
|
|
279
|
+
// Internal: exposed to tests via type assertion when they want to
|
|
280
|
+
// verify the initialized flag advanced. Not part of the public
|
|
281
|
+
// interface intentionally — production callers should listen on
|
|
282
|
+
// `events` instead.
|
|
283
|
+
// @ts-expect-error — debug accessor
|
|
284
|
+
_isInitialized() {
|
|
285
|
+
return initialized;
|
|
286
|
+
},
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Run the server on stdio. Reads one JSON-RPC line per request from
|
|
291
|
+
* stdin, writes the response (or nothing for notifications) as one line
|
|
292
|
+
* to stdout. Lines are `\n`-terminated UTF-8 JSON.
|
|
293
|
+
*
|
|
294
|
+
* Returns a promise that resolves when stdin closes. The caller can race
|
|
295
|
+
* it against `signal` to force shutdown — the stdio reader is shielded
|
|
296
|
+
* by the same signal, so an abort terminates the line loop cleanly.
|
|
297
|
+
*/
|
|
298
|
+
export async function serveStdio(options) {
|
|
299
|
+
const stdin = options.stdin ?? process.stdin;
|
|
300
|
+
const stdout = options.stdout ?? process.stdout;
|
|
301
|
+
const { server, signal } = options;
|
|
302
|
+
return new Promise((resolve) => {
|
|
303
|
+
let buffer = '';
|
|
304
|
+
let closed = false;
|
|
305
|
+
const finish = () => {
|
|
306
|
+
if (closed)
|
|
307
|
+
return;
|
|
308
|
+
closed = true;
|
|
309
|
+
stdin.off('data', onData);
|
|
310
|
+
stdin.off('end', finish);
|
|
311
|
+
stdin.off('close', finish);
|
|
312
|
+
if (signal) {
|
|
313
|
+
signal.removeEventListener('abort', finish);
|
|
314
|
+
}
|
|
315
|
+
resolve();
|
|
316
|
+
};
|
|
317
|
+
const writeFrame = (response) => {
|
|
318
|
+
stdout.write(`${JSON.stringify(response)}\n`);
|
|
319
|
+
};
|
|
320
|
+
const writeParseError = (id, message) => {
|
|
321
|
+
writeFrame({
|
|
322
|
+
jsonrpc: '2.0',
|
|
323
|
+
id,
|
|
324
|
+
error: { code: MCP_ERROR_CODES.PARSE_ERROR, message },
|
|
325
|
+
});
|
|
326
|
+
};
|
|
327
|
+
const processLine = async (line) => {
|
|
328
|
+
const trimmed = line.trim();
|
|
329
|
+
if (trimmed.length === 0)
|
|
330
|
+
return;
|
|
331
|
+
let parsed;
|
|
332
|
+
try {
|
|
333
|
+
parsed = JSON.parse(trimmed);
|
|
334
|
+
}
|
|
335
|
+
catch (error) {
|
|
336
|
+
writeParseError(null, `invalid JSON: ${error.message}`);
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
340
|
+
writeParseError(null, 'request must be a JSON object');
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
const candidate = parsed;
|
|
344
|
+
if (candidate.jsonrpc !== '2.0' || typeof candidate.method !== 'string') {
|
|
345
|
+
writeParseError(typeof candidate.id === 'number' || typeof candidate.id === 'string'
|
|
346
|
+
? candidate.id
|
|
347
|
+
: null, 'invalid JSON-RPC envelope: jsonrpc=2.0 + string method required');
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
// Stdio is a single-tenant transport — the parent process owns
|
|
351
|
+
// both ends — so we intentionally drop any inbound `meta.clientId`.
|
|
352
|
+
// Per-connection scoping only makes sense for HTTP+SSE.
|
|
353
|
+
const request = {
|
|
354
|
+
jsonrpc: '2.0',
|
|
355
|
+
method: candidate.method,
|
|
356
|
+
...(candidate.id !== undefined ? { id: candidate.id } : {}),
|
|
357
|
+
...(candidate.params !== undefined ? { params: candidate.params } : {}),
|
|
358
|
+
};
|
|
359
|
+
const response = await server.handleMessage(request);
|
|
360
|
+
if (response)
|
|
361
|
+
writeFrame(response);
|
|
362
|
+
};
|
|
363
|
+
const onData = (chunk) => {
|
|
364
|
+
buffer += typeof chunk === 'string' ? chunk : chunk.toString('utf8');
|
|
365
|
+
let newlineIndex = buffer.indexOf('\n');
|
|
366
|
+
while (newlineIndex !== -1) {
|
|
367
|
+
const line = buffer.slice(0, newlineIndex);
|
|
368
|
+
buffer = buffer.slice(newlineIndex + 1);
|
|
369
|
+
// Fire-and-forget the line handler; ordering of responses
|
|
370
|
+
// matches request ordering at the protocol level because the
|
|
371
|
+
// dispatch is microtask-serialized via the await above for
|
|
372
|
+
// each line — Node iterates `data` callbacks synchronously,
|
|
373
|
+
// and processLine awaits the dispatcher before returning so
|
|
374
|
+
// the next iteration of the loop sees the prior one settled.
|
|
375
|
+
void processLine(line);
|
|
376
|
+
newlineIndex = buffer.indexOf('\n');
|
|
377
|
+
}
|
|
378
|
+
};
|
|
379
|
+
if (signal) {
|
|
380
|
+
if (signal.aborted) {
|
|
381
|
+
finish();
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
signal.addEventListener('abort', finish, { once: true });
|
|
385
|
+
}
|
|
386
|
+
if (!stdin.readable) {
|
|
387
|
+
// Stream already closed (e.g. parent piped EOF immediately).
|
|
388
|
+
finish();
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
stdin.setEncoding('utf8');
|
|
392
|
+
stdin.on('data', onData);
|
|
393
|
+
stdin.on('end', finish);
|
|
394
|
+
stdin.on('close', finish);
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
//# sourceMappingURL=server.js.map
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
* keys stay readable English (`brief`, `ts`). No forbidden words.
|
|
32
32
|
*/
|
|
33
33
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync, renameSync, unlinkSync, } from 'node:fs';
|
|
34
|
+
import { randomBytes } from 'node:crypto';
|
|
34
35
|
import { homedir } from 'node:os';
|
|
35
36
|
import { dirname, join } from 'node:path';
|
|
36
37
|
/** Cap on stored entries per workspace. Drops oldest on overflow. */
|
|
@@ -77,7 +78,16 @@ export function append(input) {
|
|
|
77
78
|
// sibling guarantees that). P2 fix from PR #335 triple-review.
|
|
78
79
|
if (existing.length + 1 > MAX_HISTORY_ENTRIES) {
|
|
79
80
|
const trimmed = [...existing.slice(existing.length + 1 - MAX_HISTORY_ENTRIES), entry];
|
|
80
|
-
|
|
81
|
+
// β1b #52 (2026-05-26): unique-per-call tmp suffix.
|
|
82
|
+
// Previous form was a fixed `${path}.tmp`, which means two CLI
|
|
83
|
+
// processes hitting the overflow rewrite at the same moment race
|
|
84
|
+
// on the same sibling file. Whichever writeFileSync lands second
|
|
85
|
+
// can corrupt the renameSync target's content (one process's
|
|
86
|
+
// serialized buffer overwrites the other mid-flight). Append a
|
|
87
|
+
// pid + monotonic-ish timestamp + 8 hex random bytes so the tmp
|
|
88
|
+
// names are collision-proof across PIDs, concurrent calls inside
|
|
89
|
+
// one PID, and rapid re-runs that share the same ms timestamp.
|
|
90
|
+
const tmpPath = `${path}.${process.pid}.${Date.now()}.${randomBytes(4).toString('hex')}.tmp`;
|
|
81
91
|
try {
|
|
82
92
|
writeFileSync(tmpPath, trimmed.map(serialize).join('\n') + '\n', { mode: 0o600 });
|
|
83
93
|
renameSync(tmpPath, path);
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static price ladder keyed by exact model slug.
|
|
3
|
+
*
|
|
4
|
+
* Source: provider list-price pages as of 2026-05-26. Keep prefix lookup
|
|
5
|
+
* (`priceForModel`) for forward compat — a `claude-opus-4-8` from a future
|
|
6
|
+
* model-router rebind falls back onto the `claude-opus-` family entry.
|
|
7
|
+
*/
|
|
8
|
+
const PRICE_LADDER = new Map([
|
|
9
|
+
// Anthropic Claude family
|
|
10
|
+
['claude-opus-4-7', { inputUsdPerM: 15.0, outputUsdPerM: 75.0 }],
|
|
11
|
+
['claude-opus-4-6', { inputUsdPerM: 15.0, outputUsdPerM: 75.0 }],
|
|
12
|
+
['claude-sonnet-4-6', { inputUsdPerM: 3.0, outputUsdPerM: 15.0 }],
|
|
13
|
+
['claude-sonnet-4-5', { inputUsdPerM: 3.0, outputUsdPerM: 15.0 }],
|
|
14
|
+
['claude-haiku-4-5', { inputUsdPerM: 0.8, outputUsdPerM: 4.0 }],
|
|
15
|
+
// OpenAI family — sentinel entries so a `pugi config set` override lands clean.
|
|
16
|
+
['gpt-4o', { inputUsdPerM: 2.5, outputUsdPerM: 10.0 }],
|
|
17
|
+
['gpt-4o-mini', { inputUsdPerM: 0.15, outputUsdPerM: 0.6 }],
|
|
18
|
+
['o3', { inputUsdPerM: 10.0, outputUsdPerM: 40.0 }],
|
|
19
|
+
['o3-mini', { inputUsdPerM: 1.1, outputUsdPerM: 4.4 }],
|
|
20
|
+
// Mistral
|
|
21
|
+
['mistral-large', { inputUsdPerM: 2.0, outputUsdPerM: 6.0 }],
|
|
22
|
+
['mistral-small', { inputUsdPerM: 0.2, outputUsdPerM: 0.6 }],
|
|
23
|
+
// Embeddings — input-only; output is zero so the meter shows a
|
|
24
|
+
// flat per-call cost without inflating the "output" column.
|
|
25
|
+
['voyage-3', { inputUsdPerM: 0.12, outputUsdPerM: 0 }],
|
|
26
|
+
['voyage-3-large', { inputUsdPerM: 0.18, outputUsdPerM: 0 }],
|
|
27
|
+
]);
|
|
28
|
+
/**
|
|
29
|
+
* Family-prefix fallback. Used when an unknown slug starts with a
|
|
30
|
+
* known family stem (e.g. `claude-sonnet-4-7`).
|
|
31
|
+
*/
|
|
32
|
+
const FAMILY_PREFIX = [
|
|
33
|
+
['claude-opus-', { inputUsdPerM: 15.0, outputUsdPerM: 75.0 }],
|
|
34
|
+
['claude-sonnet-', { inputUsdPerM: 3.0, outputUsdPerM: 15.0 }],
|
|
35
|
+
['claude-haiku-', { inputUsdPerM: 0.8, outputUsdPerM: 4.0 }],
|
|
36
|
+
['gpt-4o', { inputUsdPerM: 2.5, outputUsdPerM: 10.0 }],
|
|
37
|
+
['o3', { inputUsdPerM: 10.0, outputUsdPerM: 40.0 }],
|
|
38
|
+
['mistral-', { inputUsdPerM: 2.0, outputUsdPerM: 6.0 }],
|
|
39
|
+
['voyage-', { inputUsdPerM: 0.12, outputUsdPerM: 0 }],
|
|
40
|
+
];
|
|
41
|
+
/**
|
|
42
|
+
* Default ladder when no model slug is known (e.g. the older admin-api
|
|
43
|
+
* still on the pre-α7 event shape that does not carry `model`). Pinned
|
|
44
|
+
* to Sonnet-tier so the meter is "honestly conservative" — close to the
|
|
45
|
+
* default dispatch lane for `reason` + `codegen` tags.
|
|
46
|
+
*/
|
|
47
|
+
const DEFAULT_PRICE = { inputUsdPerM: 3.0, outputUsdPerM: 15.0 };
|
|
48
|
+
/**
|
|
49
|
+
* Look up the price for a model slug. Resolution order:
|
|
50
|
+
* 1. Exact match in `PRICE_LADDER`.
|
|
51
|
+
* 2. Family-prefix match (`startsWith`).
|
|
52
|
+
* 3. Default Sonnet-tier rate.
|
|
53
|
+
*
|
|
54
|
+
* Pure — never throws. The cost meter calls this on every `agent.tokens`
|
|
55
|
+
* frame so it must stay branch-cheap.
|
|
56
|
+
*/
|
|
57
|
+
export function priceForModel(model) {
|
|
58
|
+
if (!model || typeof model !== 'string')
|
|
59
|
+
return DEFAULT_PRICE;
|
|
60
|
+
const exact = PRICE_LADDER.get(model);
|
|
61
|
+
if (exact)
|
|
62
|
+
return exact;
|
|
63
|
+
for (const [prefix, price] of FAMILY_PREFIX) {
|
|
64
|
+
if (model.startsWith(prefix))
|
|
65
|
+
return price;
|
|
66
|
+
}
|
|
67
|
+
return DEFAULT_PRICE;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Compute the USD cost for a single (input, output, model) triple.
|
|
71
|
+
* Returns a finite number, never NaN/Infinity, capped at zero floor so
|
|
72
|
+
* a buggy upstream that emits negative deltas (theoretically impossible
|
|
73
|
+
* via Anvil but we stay defensive) does not credit the operator's meter.
|
|
74
|
+
*
|
|
75
|
+
* Pure, deterministic, branch-cheap — called on every `agent.tokens`
|
|
76
|
+
* event so the hot path keeps the math inline.
|
|
77
|
+
*/
|
|
78
|
+
export function computeCostUsd(tokensIn, tokensOut, model) {
|
|
79
|
+
const price = priceForModel(model);
|
|
80
|
+
const safeIn = Number.isFinite(tokensIn) && tokensIn > 0 ? tokensIn : 0;
|
|
81
|
+
const safeOut = Number.isFinite(tokensOut) && tokensOut > 0 ? tokensOut : 0;
|
|
82
|
+
const usd = (safeIn * price.inputUsdPerM + safeOut * price.outputUsdPerM) / 1_000_000;
|
|
83
|
+
return Number.isFinite(usd) && usd > 0 ? usd : 0;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Format a token count for the status row. Spec (CEO 2026-05-26):
|
|
87
|
+
* - `<1000` → raw integer (e.g. `42`)
|
|
88
|
+
* - `≥1000` → one-decimal `k` (e.g. `1.2k`)
|
|
89
|
+
* - `≥1_000_000` → one-decimal `m` (e.g. `1.0m`)
|
|
90
|
+
*
|
|
91
|
+
* Negative / NaN inputs render as `0`.
|
|
92
|
+
*/
|
|
93
|
+
export function formatTokens(value) {
|
|
94
|
+
if (!Number.isFinite(value) || value <= 0)
|
|
95
|
+
return '0';
|
|
96
|
+
if (value < 1_000)
|
|
97
|
+
return Math.floor(value).toString();
|
|
98
|
+
if (value < 1_000_000)
|
|
99
|
+
return `${(value / 1_000).toFixed(1)}k`;
|
|
100
|
+
return `${(value / 1_000_000).toFixed(1)}m`;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Format USD cost for the status row. Spec (CEO 2026-05-26):
|
|
104
|
+
* - `≥ $0.01` → `$X.XX` (two decimals)
|
|
105
|
+
* - `< $0.01` (but > 0) → `$X.XXX` (three decimals, shows fractions
|
|
106
|
+
* of a cent honestly instead of rounding to `$0.00`)
|
|
107
|
+
* - `0` / NaN → `$0.00`
|
|
108
|
+
*/
|
|
109
|
+
export function formatCostUsd(usd) {
|
|
110
|
+
if (!Number.isFinite(usd) || usd <= 0)
|
|
111
|
+
return '$0.00';
|
|
112
|
+
if (usd >= 0.01)
|
|
113
|
+
return `$${usd.toFixed(2)}`;
|
|
114
|
+
return `$${usd.toFixed(3)}`;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Format an elapsed-ms duration as `1m2s` / `45s` / `2h3m`. Used by the
|
|
118
|
+
* status row's session-duration slot. Floors aggressively — we don't
|
|
119
|
+
* want sub-second decimals stealing column width.
|
|
120
|
+
*/
|
|
121
|
+
export function formatDuration(elapsedMs) {
|
|
122
|
+
if (!Number.isFinite(elapsedMs) || elapsedMs <= 0)
|
|
123
|
+
return '0s';
|
|
124
|
+
const totalSec = Math.floor(elapsedMs / 1000);
|
|
125
|
+
if (totalSec < 60)
|
|
126
|
+
return `${totalSec}s`;
|
|
127
|
+
const min = Math.floor(totalSec / 60);
|
|
128
|
+
const sec = totalSec % 60;
|
|
129
|
+
if (min < 60)
|
|
130
|
+
return `${min}m${sec.toString().padStart(2, '0')}s`;
|
|
131
|
+
const hr = Math.floor(min / 60);
|
|
132
|
+
const restMin = min % 60;
|
|
133
|
+
return `${hr}h${restMin.toString().padStart(2, '0')}m`;
|
|
134
|
+
}
|
|
135
|
+
//# sourceMappingURL=model-pricing.js.map
|