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 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 the viewer is open and how far the user's
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
- ...(TOKEN ? { authorization: `Bearer ${TOKEN}` } : {}),
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: TOKEN ? { authorization: `Bearer ${TOKEN}` } : {},
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: TOKEN ? { authorization: `Bearer ${TOKEN}` } : {},
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
- ...(TOKEN ? { authorization: `Bearer ${TOKEN}` } : {}),
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. viewerOpen is workspace-wide, so it is
822
- // said once rather than per mock.
828
+ // What the user is doing right now, per mock.
823
829
  function pendingLines(pending) {
824
- const rows = pending ?? [];
825
- const lines = rows
826
- .filter((p) => p.draft)
827
- .map(
828
- (p) =>
829
- `pending: ${p.mock} — the user is answering (${p.draft.answered} of ${p.draft.of} answered, ${p.draft.comments} comment${p.draft.comments === 1 ? "" : "s"})`,
830
- );
831
- if (lines.length === 0 && rows.some((p) => p.viewerOpen))
832
- lines.push("pending: the viewer is open");
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: TOKEN ? { authorization: `Bearer ${TOKEN}` } : {},
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: TOKEN ? { authorization: `Bearer ${TOKEN}` } : {} },
1551
+ { headers: HEADERS },
1547
1552
  );
1548
1553
  if (!res.ok) {
1549
1554
  await sleep(2000);
@@ -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 = { "content-type": "application/json" };
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;
@@ -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, "/">;
@@ -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), so an attached SSE
207
- // client means someone has the workspace open.
208
- let viewerClients = 0;
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: viewerClients > 0,
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
- viewerClients++;
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
- viewerClients--;
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
+ }
@@ -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,2 @@
1
+ import type { LogSink } from "./eventLog.ts";
2
+ export declare function createFileLog(path: string, maxBytes?: number): LogSink;
@@ -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
+ }
@@ -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 rows = [pending ?? []].flat();
241
- const lines = rows
242
- .filter((p) => p.draft)
243
- .map(
244
- (p) =>
245
- `- ${p.mock}: the user is answering (${p.draft.answered} of ${p.draft.of} answered, ${p.draft.comments} comments)`,
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
 
@@ -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 the
57
- workspace open, `draft` is their unsent progress (`null` when there is none).
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.0.0",
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",