mockpit 1.0.0 → 1.1.0
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/README.md +8 -1
- package/bin/mockpit.js +23 -18
- package/dist/mcp/server.js +23 -1
- package/dist/server/app.d.ts +3 -1
- package/dist/server/app.js +30 -8
- package/dist/server/eventLog.d.ts +27 -0
- package/dist/server/eventLog.js +187 -0
- package/dist/server/index.js +12 -0
- package/dist/server/logFile.d.ts +2 -0
- package/dist/server/logFile.js +27 -0
- package/extensions/mockpit.js +23 -9
- package/guide/topics/feedback.md +2 -2
- package/package.json +2 -2
- package/viewer/dist/index.html +7 -7
package/README.md
CHANGED
|
@@ -155,7 +155,7 @@ and `mockpit watch` (one line per Send, for a background monitor).
|
|
|
155
155
|
list (an entry that is only `{id}` keeps that surface), or `parts` to replace
|
|
156
156
|
just the marked elements.
|
|
157
157
|
- **`feedback` returns at once** with what the user sent since the agent last
|
|
158
|
-
heard, plus `pending`: whether
|
|
158
|
+
heard, plus `pending`: whether each mock is open in the viewer and how far the user's
|
|
159
159
|
draft has got (`2 of 3 answered`). Every write returns `feedback` too, and
|
|
160
160
|
each Send is delivered exactly once across all of them.
|
|
161
161
|
- **Asks are nudged.** A publish that leaves several variants with no ask
|
|
@@ -185,6 +185,13 @@ It runs locally as a small Node server (SQLite at `~/.mockpit/mockpit.db`), or o
|
|
|
185
185
|
Cloudflare Workers when your agent and browser are on different machines. See
|
|
186
186
|
**[docs/deploying.md](docs/deploying.md)**.
|
|
187
187
|
|
|
188
|
+
The server keeps an event log, one JSON line per agent call, viewer write and
|
|
189
|
+
live-feed connection (ids and shapes, never content), at
|
|
190
|
+
`~/.mockpit/events.jsonl` next to the database. It rolls over at 10 MB. Set
|
|
191
|
+
`MOCKPIT_LOG` to another path, or to `off` to disable it; on Workers it goes to
|
|
192
|
+
Workers Logs. `node scripts/inspect.ts` in a checkout reads it alongside the
|
|
193
|
+
database and Claude Code transcripts.
|
|
194
|
+
|
|
188
195
|
The server app is importable from `mockpit/server` (`createApp`, `SqlStore`,
|
|
189
196
|
`createSqliteStorage`). The embeddable viewer engine (`mockpit/viewer-embed`,
|
|
190
197
|
`mountViewer`) is gone: the viewer is self-hosted only.
|
package/bin/mockpit.js
CHANGED
|
@@ -19,6 +19,12 @@ const BASE = (process.env.MOCKPIT_URL ?? "http://localhost:8228").replace(/\/$/,
|
|
|
19
19
|
const TOKEN = process.env.MOCKPIT_TOKEN;
|
|
20
20
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
21
21
|
const PKG_VERSION = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")).version;
|
|
22
|
+
// Every request names this client and its version, so the server's event log
|
|
23
|
+
// can tell the CLI from MCP and spot one older than the server.
|
|
24
|
+
const HEADERS = {
|
|
25
|
+
"x-mockpit-client": `cli/${PKG_VERSION}`,
|
|
26
|
+
...(TOKEN ? { authorization: `Bearer ${TOKEN}` } : {}),
|
|
27
|
+
};
|
|
22
28
|
|
|
23
29
|
const HELP = `mockpit — a live visual surface for terminal coding agents
|
|
24
30
|
|
|
@@ -118,6 +124,7 @@ environment:
|
|
|
118
124
|
MOCKPIT_URL server base URL (default http://localhost:8228)
|
|
119
125
|
MOCKPIT_TOKEN bearer token for a deployed instance
|
|
120
126
|
MOCKPIT_HOST address serve binds to (default: every interface)
|
|
127
|
+
MOCKPIT_LOG serve's event log file, or "off" (default ~/.mockpit/events.jsonl)
|
|
121
128
|
MOCKPIT_SESSION fixed session id (overrides auto-detection)
|
|
122
129
|
MOCKPIT_AGENT agent name used when creating sessions
|
|
123
130
|
`;
|
|
@@ -241,7 +248,7 @@ async function api(path, init = {}, { report, fix } = {}) {
|
|
|
241
248
|
...init,
|
|
242
249
|
headers: {
|
|
243
250
|
"content-type": "application/json",
|
|
244
|
-
...
|
|
251
|
+
...HEADERS,
|
|
245
252
|
...init.headers,
|
|
246
253
|
},
|
|
247
254
|
});
|
|
@@ -364,7 +371,7 @@ async function resolveSession(flags, { create = false } = {}) {
|
|
|
364
371
|
const state = readState();
|
|
365
372
|
if (state.session && !flags["new-session"]) {
|
|
366
373
|
const ok = await fetch(`${BASE}/api/sessions/${state.session}`, {
|
|
367
|
-
headers:
|
|
374
|
+
headers: HEADERS,
|
|
368
375
|
}).then(
|
|
369
376
|
(r) => r.ok,
|
|
370
377
|
() => false,
|
|
@@ -394,7 +401,7 @@ async function resolveSession(flags, { create = false } = {}) {
|
|
|
394
401
|
async function resolveSessionByCwd(cwd = process.cwd()) {
|
|
395
402
|
try {
|
|
396
403
|
const res = await fetch(`${BASE}/api/sessions`, {
|
|
397
|
-
headers:
|
|
404
|
+
headers: HEADERS,
|
|
398
405
|
});
|
|
399
406
|
if (!res.ok) return null;
|
|
400
407
|
const sessions = await res.json();
|
|
@@ -533,7 +540,7 @@ async function uploadBytes(bytes, { filename, contentType, session, kind } = {})
|
|
|
533
540
|
method: "POST",
|
|
534
541
|
headers: {
|
|
535
542
|
"content-type": contentType ?? "application/octet-stream",
|
|
536
|
-
...
|
|
543
|
+
...HEADERS,
|
|
537
544
|
},
|
|
538
545
|
body: bytes,
|
|
539
546
|
});
|
|
@@ -818,19 +825,17 @@ function printFeedback(feedback) {
|
|
|
818
825
|
if (feedback?.length) console.log(`feedback: ${JSON.stringify(feedback)}`);
|
|
819
826
|
}
|
|
820
827
|
|
|
821
|
-
// What the user is doing right now
|
|
822
|
-
// said once rather than per mock.
|
|
828
|
+
// What the user is doing right now, per mock.
|
|
823
829
|
function pendingLines(pending) {
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
return lines;
|
|
830
|
+
return (pending ?? []).flatMap((p) =>
|
|
831
|
+
p.draft
|
|
832
|
+
? [
|
|
833
|
+
`pending: ${p.mock} — the user is answering (${p.draft.answered} of ${p.draft.of} answered, ${p.draft.comments} comment${p.draft.comments === 1 ? "" : "s"})`,
|
|
834
|
+
]
|
|
835
|
+
: p.viewerOpen
|
|
836
|
+
? [`pending: ${p.mock} — open in the viewer`]
|
|
837
|
+
: [],
|
|
838
|
+
);
|
|
834
839
|
}
|
|
835
840
|
|
|
836
841
|
function parsePublishFlags() {
|
|
@@ -1020,7 +1025,7 @@ async function apiSoft(path) {
|
|
|
1020
1025
|
let res;
|
|
1021
1026
|
try {
|
|
1022
1027
|
res = await fetch(`${BASE}${path}`, {
|
|
1023
|
-
headers:
|
|
1028
|
+
headers: HEADERS,
|
|
1024
1029
|
});
|
|
1025
1030
|
} catch {
|
|
1026
1031
|
die(`cannot reach mockpit at ${BASE}`, "mockpit serve");
|
|
@@ -1543,7 +1548,7 @@ const commands = {
|
|
|
1543
1548
|
try {
|
|
1544
1549
|
const res = await fetch(
|
|
1545
1550
|
`${BASE}/api/comments${query({ session, author: "user", after: firstAfter, wait: 60 })}`,
|
|
1546
|
-
{ headers:
|
|
1551
|
+
{ headers: HEADERS },
|
|
1547
1552
|
);
|
|
1548
1553
|
if (!res.ok) {
|
|
1549
1554
|
await sleep(2000);
|
package/dist/mcp/server.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { execFileSync } from "node:child_process";
|
|
3
3
|
import { readFileSync, statSync } from "node:fs";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
4
6
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
7
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
8
|
import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
@@ -12,8 +14,28 @@ const TOKEN = process.env.MOCKPIT_TOKEN;
|
|
|
12
14
|
const AGENT = process.env.MOCKPIT_AGENT ?? "claude-code";
|
|
13
15
|
// "code" serves the codemode catalog: one `run` tool instead of the mock tools.
|
|
14
16
|
const CODE_MODE = process.env.MOCKPIT_MCP_MODE === "code";
|
|
17
|
+
// Names this client in the server's event log; the version is how a stdio
|
|
18
|
+
// server left running across an upgrade shows up there.
|
|
19
|
+
const CLIENT = `mcp-stdio/${packageVersion()}`;
|
|
20
|
+
// Source runs as mcp/server.ts, the published copy as dist/mcp/server.js.
|
|
21
|
+
function packageVersion() {
|
|
22
|
+
for (const rel of ["../package.json", "../../package.json"]) {
|
|
23
|
+
try {
|
|
24
|
+
const pkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), rel), "utf8"));
|
|
25
|
+
if (pkg.name === "mockpit")
|
|
26
|
+
return String(pkg.version);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
// Not at this depth.
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return "unknown";
|
|
33
|
+
}
|
|
15
34
|
async function api(path, init = {}) {
|
|
16
|
-
const headers = {
|
|
35
|
+
const headers = {
|
|
36
|
+
"content-type": "application/json",
|
|
37
|
+
"x-mockpit-client": CLIENT,
|
|
38
|
+
};
|
|
17
39
|
if (TOKEN)
|
|
18
40
|
headers.authorization = `Bearer ${TOKEN}`;
|
|
19
41
|
let res;
|
package/dist/server/app.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { Hono } from "hono";
|
|
|
2
2
|
import { type FeedEvent } from "./events.ts";
|
|
3
3
|
import { type GuideTopic } from "./designGuide.ts";
|
|
4
4
|
import { type Executor, type RunLimits } from "./run.ts";
|
|
5
|
+
import { type LogSink } from "./eventLog.ts";
|
|
5
6
|
import { type Store } from "./types.ts";
|
|
6
7
|
export type { FeedEvent } from "./events.ts";
|
|
7
8
|
export type { FeedbackBatch } from "./feedbackBatch.ts";
|
|
@@ -25,6 +26,7 @@ export interface AppOptions {
|
|
|
25
26
|
maxHoldConnections?: number;
|
|
26
27
|
executor?: Executor;
|
|
27
28
|
runLimits?: Partial<RunLimits>;
|
|
29
|
+
log?: LogSink;
|
|
28
30
|
}
|
|
29
31
|
export interface LatestRelease {
|
|
30
32
|
version: string;
|
|
@@ -50,4 +52,4 @@ export interface FlowContext {
|
|
|
50
52
|
request?: Request;
|
|
51
53
|
signal?: AbortSignal;
|
|
52
54
|
}
|
|
53
|
-
export declare function createApp({ store, viewerHtml, setupText, topics, authenticate, authToken, basePath, publicRead, screenshots, version, upgradeCommand, fetchLatestRelease, onEvent, maxHoldConnections, executor, runLimits, }: AppOptions): Hono<import("hono/types").BlankEnv, import("hono/types").BlankSchema, "/">;
|
|
55
|
+
export declare function createApp({ store, viewerHtml, setupText, topics, authenticate, authToken, basePath, publicRead, screenshots, version, upgradeCommand, fetchLatestRelease, onEvent, maxHoldConnections, executor, runLimits, log, }: AppOptions): Hono<import("hono/types").BlankEnv, import("hono/types").BlankSchema, "/">;
|
package/dist/server/app.js
CHANGED
|
@@ -16,6 +16,7 @@ import { addHook, addSubscription, isPushSubscription, listHooks, notify, remove
|
|
|
16
16
|
import { expandSlots, parseSlotTags } from "./slots.js";
|
|
17
17
|
import { registerMcp } from "./mcpHttp.js";
|
|
18
18
|
import { createRunFlow, DEFAULT_RUN_LIMITS } from "./run.js";
|
|
19
|
+
import { logMiddleware, writeLog } from "./eventLog.js";
|
|
19
20
|
import { RUN_API } from "./runApi.js";
|
|
20
21
|
import { escapeHtml, renderHtmlPage, renderMermaidPage, renderSandboxedPart, STATIC_ASSET_PREFIX, staticAsset, } from "./surfacePage.js";
|
|
21
22
|
import { DEFAULT_MODE, DEFAULT_THEME_ID, isMode, themeById } from "./themes.js";
|
|
@@ -166,7 +167,7 @@ const MAX_ASKS = 20;
|
|
|
166
167
|
const MAX_OPTIONS = 20;
|
|
167
168
|
const MAX_DRAFT_COMMENTS = 50;
|
|
168
169
|
const titleFromSlug = (slug) => slug.replace(/-/g, " ").replace(/^./, (ch) => ch.toUpperCase());
|
|
169
|
-
export function createApp({ store, viewerHtml, setupText, topics = {}, authenticate, authToken, basePath, publicRead, screenshots, version, upgradeCommand, fetchLatestRelease, onEvent, maxHoldConnections = DEFAULT_MAX_HOLD_CONNECTIONS, executor, runLimits, }) {
|
|
170
|
+
export function createApp({ store, viewerHtml, setupText, topics = {}, authenticate, authToken, basePath, publicRead, screenshots, version, upgradeCommand, fetchLatestRelease, onEvent, maxHoldConnections = DEFAULT_MAX_HOLD_CONNECTIONS, executor, runLimits, log, }) {
|
|
170
171
|
const app = new Hono();
|
|
171
172
|
// The scripts topic carries the run API from runApi.ts, its one source.
|
|
172
173
|
if (topics.scripts)
|
|
@@ -188,6 +189,8 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
188
189
|
c.header("Referrer-Policy", "no-referrer");
|
|
189
190
|
return next();
|
|
190
191
|
});
|
|
192
|
+
if (log)
|
|
193
|
+
app.use("*", logMiddleware(log));
|
|
191
194
|
const bus = new EventBus();
|
|
192
195
|
if (onEvent) {
|
|
193
196
|
bus.subscribe((event) => {
|
|
@@ -203,9 +206,10 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
203
206
|
// Each holder increments on entry and releases exactly once via a guarded
|
|
204
207
|
// release() wired to every exit (stream abort, request abort, normal return).
|
|
205
208
|
let holdConnections = 0;
|
|
206
|
-
// Only the viewer holds /api/events (watch long-polls)
|
|
207
|
-
//
|
|
208
|
-
|
|
209
|
+
// Only the viewer holds /api/events (watch long-polls). Each tab names the
|
|
210
|
+
// mock it has on screen (`?viewing=`), so pending.viewerOpen is per mock: a
|
|
211
|
+
// tab on Home or on another mock doesn't read as "looking at this one".
|
|
212
|
+
const viewing = new Map();
|
|
209
213
|
const acquireHold = () => {
|
|
210
214
|
if (holdConnections >= maxHoldConnections)
|
|
211
215
|
return false;
|
|
@@ -1960,7 +1964,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
1960
1964
|
// tells the agent something ("2 of 3 answered", or nobody is looking).
|
|
1961
1965
|
const pendingOf = (mock, posts) => ({
|
|
1962
1966
|
mock: mock.slug,
|
|
1963
|
-
viewerOpen:
|
|
1967
|
+
viewerOpen: viewing.has(mock.id),
|
|
1964
1968
|
draft: mock.draft
|
|
1965
1969
|
? {
|
|
1966
1970
|
answered: Object.keys(mock.draft.answers).length,
|
|
@@ -2986,12 +2990,30 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
2986
2990
|
}
|
|
2987
2991
|
if (!acquireHold())
|
|
2988
2992
|
return c.json({ error: "too many concurrent connections" }, 503);
|
|
2989
|
-
|
|
2993
|
+
const openedAt = Date.now();
|
|
2994
|
+
const viewingId = str(c.req.query("viewing"), MAX_TITLE);
|
|
2995
|
+
if (viewingId)
|
|
2996
|
+
viewing.set(viewingId, (viewing.get(viewingId) ?? 0) + 1);
|
|
2990
2997
|
let attached = true;
|
|
2991
2998
|
const releaseHold = makeRelease();
|
|
2992
2999
|
const release = () => {
|
|
2993
|
-
if (attached)
|
|
2994
|
-
|
|
3000
|
+
if (attached) {
|
|
3001
|
+
if (viewingId) {
|
|
3002
|
+
const left = (viewing.get(viewingId) ?? 1) - 1;
|
|
3003
|
+
if (left > 0)
|
|
3004
|
+
viewing.set(viewingId, left);
|
|
3005
|
+
else
|
|
3006
|
+
viewing.delete(viewingId);
|
|
3007
|
+
}
|
|
3008
|
+
writeLog(log, {
|
|
3009
|
+
t: new Date().toISOString(),
|
|
3010
|
+
op: "sse close",
|
|
3011
|
+
client: "viewer",
|
|
3012
|
+
status: 200,
|
|
3013
|
+
ms: Date.now() - openedAt,
|
|
3014
|
+
...(viewingId ? { viewing: viewingId } : {}),
|
|
3015
|
+
});
|
|
3016
|
+
}
|
|
2995
3017
|
attached = false;
|
|
2996
3018
|
releaseHold();
|
|
2997
3019
|
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { MiddlewareHandler } from "hono";
|
|
2
|
+
export interface LogEntry {
|
|
3
|
+
t: string;
|
|
4
|
+
op: string;
|
|
5
|
+
client: string;
|
|
6
|
+
status: number;
|
|
7
|
+
ms: number;
|
|
8
|
+
session?: string;
|
|
9
|
+
project?: string;
|
|
10
|
+
mock?: string;
|
|
11
|
+
state?: string | null;
|
|
12
|
+
variant?: string;
|
|
13
|
+
version?: number;
|
|
14
|
+
delivered?: number[];
|
|
15
|
+
pending?: string[];
|
|
16
|
+
nudges?: number;
|
|
17
|
+
reply?: number;
|
|
18
|
+
viewing?: string;
|
|
19
|
+
key?: string;
|
|
20
|
+
error?: string;
|
|
21
|
+
}
|
|
22
|
+
export type LogSink = (entry: LogEntry) => void;
|
|
23
|
+
export declare function clientOf(header: string | undefined, viewer: boolean, mcp: boolean): string;
|
|
24
|
+
export declare function fromRequest(query: URLSearchParams, body: unknown, pathMock: string | undefined): Partial<LogEntry>;
|
|
25
|
+
export declare function fromResponse(body: unknown): Partial<LogEntry>;
|
|
26
|
+
export declare function writeLog(sink: LogSink | undefined, entry: LogEntry): void;
|
|
27
|
+
export declare function logMiddleware(sink: LogSink): MiddlewareHandler;
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// The event log: one entry per call that moves the loop (agent writes and
|
|
2
|
+
// reads, viewer writes, the viewer's live feed), so a session can be diagnosed
|
|
3
|
+
// after the fact — which client sent what, which reply seqs it was handed, what
|
|
4
|
+
// the agent was told was pending. Shapes and ids only, never html or comment
|
|
5
|
+
// text: the store already holds the content under the same ids, and a log that
|
|
6
|
+
// copied it would be a second, unbounded store. `scripts/inspect.ts` reads it.
|
|
7
|
+
const MAX_ERROR = 300;
|
|
8
|
+
const CLIENT = /^[\w.@/-]{1,64}$/;
|
|
9
|
+
export function clientOf(header, viewer, mcp) {
|
|
10
|
+
if (header && CLIENT.test(header))
|
|
11
|
+
return header;
|
|
12
|
+
if (viewer)
|
|
13
|
+
return "viewer";
|
|
14
|
+
return mcp ? "mcp-http" : "http";
|
|
15
|
+
}
|
|
16
|
+
const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
17
|
+
const text = (v) => typeof v === "string" && v ? v.slice(0, 200) : undefined;
|
|
18
|
+
// What the request itself names, for calls whose response doesn't (errors,
|
|
19
|
+
// feedback reads).
|
|
20
|
+
export function fromRequest(query, body, pathMock) {
|
|
21
|
+
const b = isObj(body) ? body : {};
|
|
22
|
+
return prune({
|
|
23
|
+
session: text(query.get("session")) ?? text(b.session),
|
|
24
|
+
project: text(query.get("project")) ?? text(b.project),
|
|
25
|
+
mock: pathMock ?? text(b.mock),
|
|
26
|
+
variant: text(b.variant),
|
|
27
|
+
viewing: text(query.get("viewing")),
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
// What a flow's response body says about the call.
|
|
31
|
+
export function fromResponse(body) {
|
|
32
|
+
if (!isObj(body))
|
|
33
|
+
return {};
|
|
34
|
+
const mock = isObj(body.mock) ? body.mock : undefined;
|
|
35
|
+
const post = isObj(body.post) ? body.post : undefined;
|
|
36
|
+
// POST /api/sessions answers with the bare session (201 new, 200 reclaimed by key).
|
|
37
|
+
const session = typeof body.agentSeq === "number" ? body : undefined;
|
|
38
|
+
const delivered = deliveredSeqs(body.feedback);
|
|
39
|
+
const pending = pendingLines(body.pending);
|
|
40
|
+
return prune({
|
|
41
|
+
session: text(body.sessionId) ?? text(post?.sessionId) ?? text(session?.id),
|
|
42
|
+
key: text(session?.key),
|
|
43
|
+
project: text(mock?.project) ?? text(body.project) ?? text(session?.project),
|
|
44
|
+
mock: text(mock?.slug) ??
|
|
45
|
+
text(body.slug) ??
|
|
46
|
+
(typeof body.mock === "string" ? text(body.mock) : undefined),
|
|
47
|
+
state: post ? (post.state ?? null) : undefined,
|
|
48
|
+
variant: text(post?.variant),
|
|
49
|
+
version: typeof post?.version === "number" ? post.version : undefined,
|
|
50
|
+
delivered: delivered.length ? delivered : undefined,
|
|
51
|
+
pending: pending.length ? pending : undefined,
|
|
52
|
+
nudges: Array.isArray(body.nudges) && body.nudges.length ? body.nudges.length : undefined,
|
|
53
|
+
reply: isObj(body.reply) && typeof body.reply.seq === "number" ? body.reply.seq : undefined,
|
|
54
|
+
error: typeof body.error === "string" ? body.error.slice(0, MAX_ERROR) : undefined,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
function deliveredSeqs(feedback) {
|
|
58
|
+
if (!Array.isArray(feedback))
|
|
59
|
+
return [];
|
|
60
|
+
const seqs = [];
|
|
61
|
+
for (const batch of feedback) {
|
|
62
|
+
if (!isObj(batch))
|
|
63
|
+
continue;
|
|
64
|
+
if (isObj(batch.reply) && typeof batch.reply.seq === "number")
|
|
65
|
+
seqs.push(batch.reply.seq);
|
|
66
|
+
for (const c of Array.isArray(batch.comments) ? batch.comments : []) {
|
|
67
|
+
if (isObj(c) && typeof c.seq === "number")
|
|
68
|
+
seqs.push(c.seq);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return seqs.sort((a, b) => a - b);
|
|
72
|
+
}
|
|
73
|
+
function pendingLines(pending) {
|
|
74
|
+
const rows = Array.isArray(pending) ? pending : isObj(pending) ? [pending] : [];
|
|
75
|
+
return rows.flatMap((p) => {
|
|
76
|
+
if (!isObj(p) || typeof p.mock !== "string")
|
|
77
|
+
return [];
|
|
78
|
+
if (isObj(p.draft))
|
|
79
|
+
return [`${p.mock} ${p.draft.answered}/${p.draft.of} answered`];
|
|
80
|
+
return p.viewerOpen ? [`${p.mock} open`] : [];
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
function prune(entry) {
|
|
84
|
+
for (const k of Object.keys(entry))
|
|
85
|
+
if (entry[k] === undefined)
|
|
86
|
+
delete entry[k];
|
|
87
|
+
return entry;
|
|
88
|
+
}
|
|
89
|
+
// A sink that throws must never fail the request it describes.
|
|
90
|
+
export function writeLog(sink, entry) {
|
|
91
|
+
if (!sink)
|
|
92
|
+
return;
|
|
93
|
+
try {
|
|
94
|
+
sink(entry);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
// The log is diagnostics; losing an entry beats losing the call.
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// The viewer's reads (it refetches on every feed event) would drown the agent's
|
|
101
|
+
// calls, so only its writes and its feed connection are logged.
|
|
102
|
+
function loggable(c, viewer) {
|
|
103
|
+
const path = c.req.path;
|
|
104
|
+
if (path === "/mcp")
|
|
105
|
+
return c.req.method === "POST";
|
|
106
|
+
if (!path.startsWith("/api/"))
|
|
107
|
+
return false;
|
|
108
|
+
return path === "/api/events" || c.req.method !== "GET" || !viewer;
|
|
109
|
+
}
|
|
110
|
+
const isJson = (type) => !!type?.includes("application/json");
|
|
111
|
+
export function logMiddleware(sink) {
|
|
112
|
+
return async (c, next) => {
|
|
113
|
+
const start = Date.now();
|
|
114
|
+
await next();
|
|
115
|
+
const viewer = c.req.header("sec-fetch-site") === "same-origin";
|
|
116
|
+
if (!loggable(c, viewer))
|
|
117
|
+
return;
|
|
118
|
+
try {
|
|
119
|
+
const url = new URL(c.req.url);
|
|
120
|
+
// Handlers already parsed the body; Hono hands back its cached copy.
|
|
121
|
+
const reqBody = isJson(c.req.header("content-type"))
|
|
122
|
+
? await c.req.json().catch(() => undefined)
|
|
123
|
+
: undefined;
|
|
124
|
+
// Never clone a non-JSON body: a clone of the SSE stream is a tee branch
|
|
125
|
+
// nobody reads, which buffers every event for the connection's life.
|
|
126
|
+
const resBody = isJson(c.res.headers.get("content-type"))
|
|
127
|
+
? await c.res
|
|
128
|
+
.clone()
|
|
129
|
+
.json()
|
|
130
|
+
.catch(() => undefined)
|
|
131
|
+
: undefined;
|
|
132
|
+
const mcp = c.req.path === "/mcp";
|
|
133
|
+
const entry = {
|
|
134
|
+
t: new Date().toISOString(),
|
|
135
|
+
op: mcp ? mcpOp(reqBody) : `${c.req.method} ${c.req.path}`,
|
|
136
|
+
client: clientOf(c.req.header("x-mockpit-client"), viewer, mcp),
|
|
137
|
+
status: c.res.status,
|
|
138
|
+
ms: Date.now() - start,
|
|
139
|
+
};
|
|
140
|
+
if (mcp) {
|
|
141
|
+
const call = isObj(reqBody) && isObj(reqBody.params) ? reqBody.params : {};
|
|
142
|
+
Object.assign(entry, fromRequest(url.searchParams, call.arguments, undefined));
|
|
143
|
+
Object.assign(entry, fromMcpResult(resBody));
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
const pathMock = /^\/api\/mocks\/([^/]+)/.exec(c.req.path)?.[1];
|
|
147
|
+
Object.assign(entry, fromRequest(url.searchParams, reqBody, pathMock));
|
|
148
|
+
Object.assign(entry, fromResponse(resBody));
|
|
149
|
+
}
|
|
150
|
+
writeLog(sink, entry);
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
// Same as writeLog: diagnostics never fail the call.
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
function mcpOp(body) {
|
|
158
|
+
if (!isObj(body))
|
|
159
|
+
return "mcp";
|
|
160
|
+
if (body.method === "tools/call" && isObj(body.params)) {
|
|
161
|
+
return `mcp ${text(body.params.name) ?? "?"}`;
|
|
162
|
+
}
|
|
163
|
+
return `mcp ${text(body.method) ?? "?"}`;
|
|
164
|
+
}
|
|
165
|
+
// A tool result carries the flow's JSON body: structured where the tool
|
|
166
|
+
// declares an output schema, else as its text content.
|
|
167
|
+
function fromMcpResult(body) {
|
|
168
|
+
if (!isObj(body))
|
|
169
|
+
return {};
|
|
170
|
+
if (isObj(body.error))
|
|
171
|
+
return { error: String(body.error.message).slice(0, MAX_ERROR) };
|
|
172
|
+
const result = isObj(body.result) ? body.result : {};
|
|
173
|
+
if (isObj(result.structuredContent))
|
|
174
|
+
return fromResponse(result.structuredContent);
|
|
175
|
+
const first = Array.isArray(result.content) ? result.content[0] : undefined;
|
|
176
|
+
const raw = isObj(first) && typeof first.text === "string" ? first.text : undefined;
|
|
177
|
+
if (raw === undefined)
|
|
178
|
+
return {};
|
|
179
|
+
if (result.isError)
|
|
180
|
+
return { error: raw.slice(0, MAX_ERROR) };
|
|
181
|
+
try {
|
|
182
|
+
return fromResponse(JSON.parse(raw));
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
return {};
|
|
186
|
+
}
|
|
187
|
+
}
|
package/dist/server/index.js
CHANGED
|
@@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url";
|
|
|
6
6
|
import { createApp } from "./app.js";
|
|
7
7
|
import { createNodeExecutor } from "./codeRunner.js";
|
|
8
8
|
import { GUIDE_TOPICS } from "./designGuide.js";
|
|
9
|
+
import { createFileLog } from "./logFile.js";
|
|
9
10
|
import { SqlStore } from "./sqlStore.js";
|
|
10
11
|
import { createSqliteStorage } from "./sqliteStorage.js";
|
|
11
12
|
// Source layout puts this file at server/index.ts; the published package runs
|
|
@@ -33,6 +34,16 @@ const publicRead = pr === "session" || pr === "full" ? pr : undefined;
|
|
|
33
34
|
const dbPath = process.env.MOCKPIT_DB ?? join(homedir(), ".mockpit", "mockpit.db");
|
|
34
35
|
const store = new SqlStore(createSqliteStorage(dbPath));
|
|
35
36
|
console.log(`mockpit store: SQLite at ${dbPath}`);
|
|
37
|
+
// The event log is on by default, next to the database, so a session can be
|
|
38
|
+
// diagnosed after the fact without having planned to (`scripts/inspect.ts`).
|
|
39
|
+
// MOCKPIT_LOG names another file; "off" disables it. An in-memory database
|
|
40
|
+
// gets none unless asked: it is a throwaway run.
|
|
41
|
+
const logSetting = process.env.MOCKPIT_LOG;
|
|
42
|
+
const logPath = logSetting === "off"
|
|
43
|
+
? undefined
|
|
44
|
+
: logSetting || (dbPath === ":memory:" ? undefined : join(dirname(dbPath), "events.jsonl"));
|
|
45
|
+
if (logPath)
|
|
46
|
+
console.log(`mockpit event log: ${logPath}`);
|
|
36
47
|
const app = createApp({
|
|
37
48
|
store,
|
|
38
49
|
viewerHtml,
|
|
@@ -46,6 +57,7 @@ const app = createApp({
|
|
|
46
57
|
upgradeCommand: "npm install -g mockpit",
|
|
47
58
|
// `run` scripts execute in QuickJS inside a worker thread per run.
|
|
48
59
|
executor: createNodeExecutor(),
|
|
60
|
+
log: logPath ? createFileLog(logPath) : undefined,
|
|
49
61
|
});
|
|
50
62
|
const port = Number(process.env.PORT ?? 8228);
|
|
51
63
|
// MOCKPIT_HOST (or `serve --host`) restricts the listener to one address.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { appendFileSync, mkdirSync, renameSync, statSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
// One file plus one rotated predecessor caps the log at twice this, so leaving
|
|
4
|
+
// it on by default costs a bounded slice of disk.
|
|
5
|
+
const DEFAULT_MAX_BYTES = 10 * 1024 * 1024;
|
|
6
|
+
// Appends JSONL to `path`, rolling it to `<path>.1` past `maxBytes`. Writes are
|
|
7
|
+
// synchronous so lines never interleave and a crash loses nothing already
|
|
8
|
+
// answered; the loop's call rate makes that cost negligible.
|
|
9
|
+
export function createFileLog(path, maxBytes = DEFAULT_MAX_BYTES) {
|
|
10
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
11
|
+
let size = 0;
|
|
12
|
+
try {
|
|
13
|
+
size = statSync(path).size;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
// No file yet.
|
|
17
|
+
}
|
|
18
|
+
return (entry) => {
|
|
19
|
+
const line = `${JSON.stringify(entry)}\n`;
|
|
20
|
+
if (size > 0 && size + line.length > maxBytes) {
|
|
21
|
+
renameSync(path, `${path}.1`);
|
|
22
|
+
size = 0;
|
|
23
|
+
}
|
|
24
|
+
appendFileSync(path, line);
|
|
25
|
+
size += Buffer.byteLength(line);
|
|
26
|
+
};
|
|
27
|
+
}
|
package/extensions/mockpit.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
2
3
|
import { readFile } from "node:fs/promises";
|
|
3
4
|
import { basename, resolve } from "node:path";
|
|
4
5
|
|
|
@@ -127,8 +128,19 @@ function resolveProjectName(cwd) {
|
|
|
127
128
|
);
|
|
128
129
|
}
|
|
129
130
|
|
|
131
|
+
// Names this client in the server's event log, with the version that would
|
|
132
|
+
// show it running behind the server.
|
|
133
|
+
const CLIENT = `pi/${(() => {
|
|
134
|
+
try {
|
|
135
|
+
return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
136
|
+
} catch {
|
|
137
|
+
return "unknown";
|
|
138
|
+
}
|
|
139
|
+
})()}`;
|
|
140
|
+
|
|
130
141
|
function authHeaders(extra = {}) {
|
|
131
142
|
return {
|
|
143
|
+
"x-mockpit-client": CLIENT,
|
|
132
144
|
...(process.env.MOCKPIT_TOKEN ? { authorization: `Bearer ${process.env.MOCKPIT_TOKEN}` } : {}),
|
|
133
145
|
...extra,
|
|
134
146
|
};
|
|
@@ -234,17 +246,19 @@ function feedbackLines(feedback) {
|
|
|
234
246
|
return lines;
|
|
235
247
|
}
|
|
236
248
|
|
|
237
|
-
// What the user is doing right now, so the agent can say "take your time".
|
|
238
|
-
// viewerOpen is workspace-wide, so it is said once rather than per mock.
|
|
249
|
+
// What the user is doing right now, per mock, so the agent can say "take your time".
|
|
239
250
|
function pendingLines(pending) {
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
.
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
251
|
+
const lines = [pending ?? []]
|
|
252
|
+
.flat()
|
|
253
|
+
.flatMap((p) =>
|
|
254
|
+
p.draft
|
|
255
|
+
? [
|
|
256
|
+
`- ${p.mock}: the user is answering (${p.draft.answered} of ${p.draft.of} answered, ${p.draft.comments} comments)`,
|
|
257
|
+
]
|
|
258
|
+
: p.viewerOpen
|
|
259
|
+
? [`- ${p.mock}: open in the viewer`]
|
|
260
|
+
: [],
|
|
246
261
|
);
|
|
247
|
-
if (lines.length === 0 && rows.some((p) => p.viewerOpen)) lines.push("- the viewer is open");
|
|
248
262
|
return lines.length ? ["Pending:", ...lines] : [];
|
|
249
263
|
}
|
|
250
264
|
|
package/guide/topics/feedback.md
CHANGED
|
@@ -53,8 +53,8 @@ It returns at once:
|
|
|
53
53
|
```
|
|
54
54
|
|
|
55
55
|
`feedback` holds one batch per mock, in delivery order. `pending` is what the
|
|
56
|
-
user is doing right now, per mock: `viewerOpen` says a browser has
|
|
57
|
-
|
|
56
|
+
user is doing right now, per mock: `viewerOpen` says a browser has that
|
|
57
|
+
mock on screen, `draft` is their unsent progress (`null` when there is none).
|
|
58
58
|
An empty `feedback` with a draft means "still answering": tell the user to take
|
|
59
59
|
their time, don't ask again. `mockpit read` returns `pending` too, without
|
|
60
60
|
taking any feedback.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mockpit",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "A design-decision loop for terminal coding agents — agents publish mocks with states and variants, ask questions and expose knobs; you answer, tune and comment, then send one reply.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agent-tools",
|
|
@@ -82,7 +82,7 @@
|
|
|
82
82
|
"lint:fix": "oxlint . --fix",
|
|
83
83
|
"changeset": "changeset",
|
|
84
84
|
"changeset:status": "changeset status",
|
|
85
|
-
"release:version": "changeset version",
|
|
85
|
+
"release:version": "changeset version && npm install --package-lock-only --ignore-scripts",
|
|
86
86
|
"typecheck": "tsc --noEmit && tsc -p tsconfig.workers.json --noEmit && tsc -p tsconfig.viewer.json --noEmit",
|
|
87
87
|
"clean:dist": "node scripts/clean-dist.ts",
|
|
88
88
|
"build": "npm run clean:dist && tsc -p tsconfig.build.json && npm run build:viewer",
|