@wirecat/cli-core 0.465.0 → 0.467.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.
Files changed (51) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/README.md +4 -2
  3. package/dist/cli/command-runs.d.ts +38 -10
  4. package/dist/cli/command-runs.js +51 -17
  5. package/dist/cli/context.d.ts +3 -0
  6. package/dist/cli/context.js +15 -3
  7. package/dist/cli/index.d.ts +3 -3
  8. package/dist/cli/index.js +1 -1
  9. package/dist/cli/messenger/account-command.js +3 -0
  10. package/dist/cli/messenger/context.d.ts +9 -0
  11. package/dist/cli/messenger/context.js +6 -4
  12. package/dist/cli/messenger/mcp-command.js +8 -1
  13. package/dist/cli/messenger/observed.js +10 -2
  14. package/dist/cli/messenger/report.js +48 -3
  15. package/dist/cli/messenger/serve-relay.d.ts +1 -0
  16. package/dist/cli/program.d.ts +1 -1
  17. package/dist/cli/program.js +7 -3
  18. package/dist/cli/settings.d.ts +19 -6
  19. package/dist/cli/settings.js +14 -3
  20. package/dist/cli/shared-command-exports.d.ts +12 -4
  21. package/dist/entities/account-profile.d.ts +2 -0
  22. package/dist/entities/account-profile.js +2 -0
  23. package/dist/entities/index.d.ts +40 -0
  24. package/dist/entities/recorded-run.d.ts +39 -0
  25. package/dist/entities/recorded-run.js +14 -1
  26. package/dist/mcp/agent.d.ts +3 -0
  27. package/dist/mcp/agent.js +2 -0
  28. package/dist/mcp/all-profiles.d.ts +12 -2
  29. package/dist/mcp/all-profiles.js +38 -8
  30. package/dist/mcp/bot/server.js +8 -3
  31. package/dist/mcp/caller.d.ts +38 -0
  32. package/dist/mcp/caller.js +95 -0
  33. package/dist/mcp/http/oauth.js +6 -2
  34. package/dist/mcp/server.js +19 -7
  35. package/dist/mcp/surface.js +11 -2
  36. package/dist/mcp/tool.d.ts +4 -1
  37. package/dist/mcp/tool.js +21 -11
  38. package/dist/runs/events.d.ts +16 -4
  39. package/dist/runs/events.js +4 -0
  40. package/dist/runs/log-attributes.js +7 -1
  41. package/dist/runs/log-events.js +5 -0
  42. package/dist/runs/recording.d.ts +10 -3
  43. package/dist/runs/recording.js +59 -15
  44. package/dist/runs/run-context.d.ts +55 -4
  45. package/dist/runs/run-context.js +34 -3
  46. package/dist/runs/run.d.ts +37 -11
  47. package/dist/runs/run.js +201 -52
  48. package/dist/runs/scope.d.ts +12 -0
  49. package/dist/runs/scope.js +6 -0
  50. package/dist/services/patience.js +5 -1
  51. package/package.json +1 -1
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { McpServer } from "@modelcontextprotocol/server";
2
3
  import { serveStdio } from "@modelcontextprotocol/server/stdio";
3
4
  import { mcpErrorResult } from "@wirecat/cli-base";
@@ -9,6 +10,7 @@ import { itemOf } from "../../cli/envelope.js";
9
10
  import { wholeList } from "../../cli/messenger/chat-answers.js";
10
11
  import { MESSENGER_CHANGE_REFUSAL, readOnlyLockOf, readOnlyRefusal } from "../../ports/app.js";
11
12
  import { runCommandForAgent, toolsFromCommands } from "../agent.js";
13
+ import { callerOf } from "../caller.js";
12
14
  import { normal, registerCommands } from "../surface.js";
13
15
  import { agentJson } from "../text.js";
14
16
  import { answered } from "../tool.js";
@@ -48,7 +50,7 @@ export const createBotServer = (options) => {
48
50
  const skill = options.skill ? skillResource(bot.app, options.skill) : undefined;
49
51
  // One at a time: the commands share the journal, the registry and the local copy.
50
52
  let queue = Promise.resolve();
51
- const invoke = (path, input) => {
53
+ const invoke = (path, input, caller) => {
52
54
  const next = queue.then(() => runCommandForAgent(program, path, path[0] === "config" ? { ...input, bot: true } : input, {
53
55
  profile: settings.profile,
54
56
  readOnly: readOnlyLockOf(bot.app, env) !== undefined,
@@ -56,11 +58,13 @@ export const createBotServer = (options) => {
56
58
  levels,
57
59
  ...(options.keyOf ? { keyOf: options.keyOf } : {}),
58
60
  authority,
61
+ ...(caller ? { caller } : {}),
59
62
  }));
60
63
  queue = next.catch(() => undefined);
61
64
  return next;
62
65
  };
63
66
  const build = () => {
67
+ const callers = { env, client: () => server.server.getClientVersion(), connectionId: randomUUID() };
64
68
  const server = new McpServer({ name: `${command}-bot-${settings.profile}`, version: bot.app.version }, {
65
69
  instructions: botInstructions({
66
70
  command,
@@ -82,8 +86,9 @@ export const createBotServer = (options) => {
82
86
  writes: tool.writes,
83
87
  availability: tool.availability,
84
88
  input: tool.input,
85
- run: async (args) => {
86
- const answer = await invoke(tool.path, args);
89
+ run: async (args, ctx) => {
90
+ const caller = callerOf(ctx, `${command}_${tool.command.replaceAll(" ", "_")}`, callers);
91
+ const answer = await invoke(tool.path, args, caller);
87
92
  return "error" in answer
88
93
  ? mcpErrorResult(JSON.parse(agentJson(answer.error)))
89
94
  : answered(body(tool.command, answer.result));
@@ -0,0 +1,38 @@
1
+ import { type RunCaller } from "../runs/run-context.js";
2
+ export { currentCaller, withCaller } from "../runs/run-context.js";
3
+ /** The `_meta` key the every-profile server names the real caller under, for the profile server it passes a call to. */
4
+ export declare const CALLER_META_KEY = "wirecat.dev/caller";
5
+ /** The name the every-profile server gives itself in `initialize`; only a connection from it may name a caller. */
6
+ export declare const PROXY_CLIENT_NAME = "all-profiles";
7
+ /** What a tool call's handler is given, reduced to what identifies the caller. */
8
+ export interface CallContext {
9
+ sessionId?: string | undefined;
10
+ mcpReq?: {
11
+ id?: string | number;
12
+ _meta?: Record<string, unknown> | undefined;
13
+ } | undefined;
14
+ http?: {
15
+ authInfo?: {
16
+ clientId: string;
17
+ extra?: Record<string, unknown> | undefined;
18
+ } | undefined;
19
+ } | undefined;
20
+ }
21
+ export interface CallerSource {
22
+ /** This server's environment: the coding agent that started a stdio server. Never read for an HTTP call. */
23
+ env: NodeJS.ProcessEnv;
24
+ /** The client's `initialize`, when this connection had one. */
25
+ client?: () => {
26
+ name: string;
27
+ version?: string;
28
+ } | undefined;
29
+ /** One id per stdio connection, made when the server starts. */
30
+ connectionId?: string;
31
+ }
32
+ /**
33
+ * Who made one MCP call. Over HTTP the server's own environment is somebody else's — whoever started `mcp --http` —
34
+ * so the caller is the OAuth client (its registered name and a hash of its id) and the HTTP session, if any. Over
35
+ * stdio the agent that started the server is the caller, and `initialize` names its client. A call the every-profile
36
+ * server passed on carries the caller it worked out, trusted only on a connection that server opened.
37
+ */
38
+ export declare const callerOf: (ctx: CallContext | undefined, tool: string, source: CallerSource) => RunCaller;
@@ -0,0 +1,95 @@
1
+ import { CLIENT_INFO_META_KEY } from "@modelcontextprotocol/server";
2
+ import { agentOf, clientHash } from "../runs/run-context.js";
3
+ export { currentCaller, withCaller } from "../runs/run-context.js";
4
+ /** The `_meta` key the every-profile server names the real caller under, for the profile server it passes a call to. */
5
+ export const CALLER_META_KEY = "wirecat.dev/caller";
6
+ /** The name the every-profile server gives itself in `initialize`; only a connection from it may name a caller. */
7
+ export const PROXY_CLIENT_NAME = "all-profiles";
8
+ const NAME = /^[\w .@()/+-]{1,64}$/;
9
+ const plain = (value) => typeof value === "string" && NAME.test(value.trim()) ? value.trim() : undefined;
10
+ const clientFrom = (value) => {
11
+ if (!value || typeof value !== "object")
12
+ return undefined;
13
+ const { name, version } = value;
14
+ const named = plain(name);
15
+ if (!named)
16
+ return undefined;
17
+ const versioned = plain(version);
18
+ return { name: named, ...(versioned ? { version: versioned } : {}) };
19
+ };
20
+ /**
21
+ * Who made one MCP call. Over HTTP the server's own environment is somebody else's — whoever started `mcp --http` —
22
+ * so the caller is the OAuth client (its registered name and a hash of its id) and the HTTP session, if any. Over
23
+ * stdio the agent that started the server is the caller, and `initialize` names its client. A call the every-profile
24
+ * server passed on carries the caller it worked out, trusted only on a connection that server opened.
25
+ */
26
+ export const callerOf = (ctx, tool, source) => {
27
+ const meta = ctx?.mcpReq?._meta;
28
+ const requestId = ctx?.mcpReq?.id;
29
+ const initialized = source.client?.();
30
+ const proxied = initialized?.name === PROXY_CLIENT_NAME ? passedOn(meta?.[CALLER_META_KEY], tool) : undefined;
31
+ if (proxied)
32
+ return proxied;
33
+ const client = clientFrom(meta?.[CLIENT_INFO_META_KEY]) ?? clientFrom(initialized);
34
+ const mcp = (connection) => ({
35
+ tool,
36
+ ...(requestId === undefined ? {} : { request_id: String(requestId) }),
37
+ ...(connection ? { connection_id: connection } : {}),
38
+ });
39
+ const auth = ctx?.http?.authInfo;
40
+ if (auth) {
41
+ const registered = plain(auth.extra?.clientName);
42
+ return {
43
+ via: "mcp-http",
44
+ agent: {
45
+ kind: "mcp-client",
46
+ session_id: ctx?.sessionId ?? null,
47
+ client: {
48
+ name: registered ?? client?.name ?? "unknown",
49
+ ...(client?.version ? { version: client.version } : {}),
50
+ id: clientHash(auth.clientId),
51
+ },
52
+ },
53
+ mcp: mcp(ctx?.sessionId),
54
+ };
55
+ }
56
+ const agent = agentOf(source.env) ?? { kind: "mcp-client", session_id: null };
57
+ return { via: "mcp-stdio", agent: { ...agent, ...(client ? { client } : {}) }, mcp: mcp(source.connectionId) };
58
+ };
59
+ /** The caller the every-profile server named, field by field: nothing else it sent reaches a run. */
60
+ const passedOn = (value, tool) => {
61
+ if (!value || typeof value !== "object")
62
+ return undefined;
63
+ const { via, agent, mcp } = value;
64
+ if (via !== "mcp-stdio" && via !== "mcp-http")
65
+ return undefined;
66
+ const a = agent && typeof agent === "object" ? agent : undefined;
67
+ const kind = plain(a?.kind);
68
+ const session = typeof a?.session_id === "string" && /^[A-Za-z0-9._:-]{1,128}$/.test(a.session_id) ? a.session_id : null;
69
+ const client = clientFrom(a?.client);
70
+ const id = a?.client && typeof a.client === "object" ? a.client.id : undefined;
71
+ const m = mcp && typeof mcp === "object" ? mcp : {};
72
+ const request = plain(m.request_id);
73
+ const connection = typeof m.connection_id === "string" && /^[\w-]{1,128}$/.test(m.connection_id) ? m.connection_id : undefined;
74
+ return {
75
+ via,
76
+ ...(kind
77
+ ? {
78
+ agent: {
79
+ kind,
80
+ session_id: session,
81
+ ...(a?.from_chat === true ? { from_chat: true } : {}),
82
+ ...(client
83
+ ? { client: { ...client, ...(typeof id === "string" && /^[0-9a-f]{8}$/.test(id) ? { id } : {}) } }
84
+ : {}),
85
+ },
86
+ }
87
+ : {}),
88
+ mcp: {
89
+ tool,
90
+ ...(request ? { request_id: request } : {}),
91
+ ...(connection ? { connection_id: connection } : {}),
92
+ proxied: true,
93
+ },
94
+ };
95
+ };
@@ -201,7 +201,7 @@ export const ownerLogin = ({ issuer, resource, file, appName, onCode, now = () =
201
201
  retryAt = now() + WRONG_CODE_WAIT_MS;
202
202
  return throttled();
203
203
  }
204
- return page(params, client, `Wrong code. ${WRONG_TRIES - wrong} tries left.`);
204
+ return page(params, client, `Wrong code. Each code works for one login: after an app logs in, a new one appears in WireCat Desktop and in "${appName} mcp server status". ${WRONG_TRIES - wrong} tries left.`);
205
205
  }
206
206
  wrong = 0;
207
207
  freshCode();
@@ -258,15 +258,19 @@ export const ownerLogin = ({ issuer, resource, file, appName, onCode, now = () =
258
258
  const verifier = {
259
259
  verifyAccessToken: async (presented) => {
260
260
  const seconds = Math.floor(now() / 1000);
261
- const found = store.read().tokens.find((one) => one.kind === "access" && one.hash === hash(presented));
261
+ const stored = store.read();
262
+ const found = stored.tokens.find((one) => one.kind === "access" && one.hash === hash(presented));
262
263
  if (!found || found.expiresAt <= seconds)
263
264
  throw new OAuthError(OAuthErrorCode.InvalidToken, "the token is unknown or expired");
265
+ // The name the app registered with, so a run can say which app called; the id itself never leaves here unhashed.
266
+ const clientName = stored.clients.find((one) => one.id === found.clientId)?.name;
264
267
  return {
265
268
  token: presented,
266
269
  clientId: found.clientId,
267
270
  scopes: [],
268
271
  expiresAt: found.expiresAt,
269
272
  resource,
273
+ ...(clientName ? { extra: { clientName } } : {}),
270
274
  };
271
275
  },
272
276
  };
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { McpServer } from "@modelcontextprotocol/server";
2
3
  import { StdioServerTransport, serveStdio } from "@modelcontextprotocol/server/stdio";
3
4
  import { CliError, mcpErrorResult } from "@wirecat/cli-base";
@@ -25,6 +26,7 @@ import { levelFor, readKeysForCommand } from "../sends/permissions.js";
25
26
  import { openStore } from "../store/store.js";
26
27
  import { runCommandForAgent, toolsFromCommands } from "./agent.js";
27
28
  import { agentLog } from "./agent-log.js";
29
+ import { callerOf, withCaller } from "./caller.js";
28
30
  import { mcpHttpMarker } from "./http/state.js";
29
31
  import { instructions } from "./instructions.js";
30
32
  import { registerPrompts } from "./prompts.js";
@@ -195,7 +197,7 @@ export const createServer = (command, context, messenger, sessionOptions) => {
195
197
  * One command at a time, on the session's connection, opened only if the command asks for it. A failure that may
196
198
  * have been the connection's drops it, as the session does for its own calls.
197
199
  */
198
- const invoke = (one, input, env) => new Promise((resolve) => {
200
+ const invoke = (one, input, env, caller) => new Promise((resolve) => {
199
201
  let finish;
200
202
  let held;
201
203
  const connection = () => new Promise((lend, refuse) => {
@@ -219,6 +221,7 @@ export const createServer = (command, context, messenger, sessionOptions) => {
219
221
  stdin,
220
222
  connection,
221
223
  environment: agentEnvironment,
224
+ caller,
222
225
  }).then(async (answer) => {
223
226
  finish?.("error" in answer && !harmless(answer.error.error.code));
224
227
  await held?.catch(() => undefined);
@@ -226,12 +229,12 @@ export const createServer = (command, context, messenger, sessionOptions) => {
226
229
  });
227
230
  });
228
231
  let queue = Promise.resolve();
229
- const queued = (one, input, env) => {
230
- const next = queue.then(() => invoke(one, input, env));
232
+ const queued = (one, input, env, caller) => {
233
+ const next = queue.then(() => invoke(one, input, env, caller));
231
234
  queue = next.catch(() => undefined);
232
235
  return next;
233
236
  };
234
- const asCommand = (one) => ({
237
+ const asCommand = (one, source) => ({
235
238
  title: one.title,
236
239
  description: one.description,
237
240
  writes: one.writes,
@@ -243,7 +246,9 @@ export const createServer = (command, context, messenger, sessionOptions) => {
243
246
  // The caller's trace reaches the run, its send journal lines and its record, as `TRACEPARENT` does.
244
247
  const traceparent = ctx?.mcpReq?._meta?.traceparent;
245
248
  const env = typeof traceparent === "string" ? { ...context.env, TRACEPARENT: traceparent } : context.env;
246
- const answer = await queued(one, args, env);
249
+ const caller = callerOf(ctx, keyOfCommand(one.command), source);
250
+ // The connection the session opens for this call is recorded under the same caller.
251
+ const answer = await withCaller(caller, () => queued(one, args, env, caller));
247
252
  const result = "error" in answer
248
253
  ? mcpErrorResult(JSON.parse(agentJson(answer.error)))
249
254
  : (() => {
@@ -266,6 +271,12 @@ export const createServer = (command, context, messenger, sessionOptions) => {
266
271
  },
267
272
  });
268
273
  const build = () => {
274
+ // Built once for a stdio connection; over HTTP the session or the OAuth client identifies the caller instead.
275
+ const callers = {
276
+ env: context.env,
277
+ client: () => server.server.getClientVersion(),
278
+ connectionId: randomUUID(),
279
+ };
269
280
  const server = new McpServer({ name: app.command, version: app.version }, {
270
281
  instructions: instructions({
271
282
  command: app.command,
@@ -305,11 +316,12 @@ export const createServer = (command, context, messenger, sessionOptions) => {
305
316
  env: context.env,
306
317
  ...(context.keyring ? { keyring: context.keyring } : {}),
307
318
  embedders,
308
- history: settings.keepFailedRuns,
319
+ history: settings.record,
309
320
  },
321
+ callers,
310
322
  };
311
323
  registerCommands(server, app.command, {
312
- ...Object.fromEntries(commands.map((one) => [one.command, asCommand(one)])),
324
+ ...Object.fromEntries(commands.map((one) => [one.command, asCommand(one, callers)])),
313
325
  ...Object.fromEntries(Object.entries(surfaceCommands({ ...pictures, status }, registration)).map(([name, one]) => [
314
326
  name,
315
327
  {
@@ -1,8 +1,10 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { toJsonSchema, toStandardJsonSchema } from "@valibot/to-json-schema";
2
3
  import { CliError } from "@wirecat/cli-base";
3
4
  import * as v from "valibot";
4
5
  import { answerSchema, listOf } from "../cli/envelope.js";
5
6
  import { ENTITIES } from "../entities/index.js";
7
+ import { callerOf, currentCaller, withCaller } from "./caller.js";
6
8
  import { agentArguments } from "./text.js";
7
9
  import { answered, commandOf, entryRunner, failed, inputOf, outputSchemaOf, READ, syncAllowedFor, UNTRUSTED, UNTRUSTED_NEUTRAL, WRITE, } from "./tool.js";
8
10
  const MATCHES = 8;
@@ -113,6 +115,12 @@ isMessenger) => {
113
115
  });
114
116
  return answered(list);
115
117
  });
118
+ // A CLI's own server runs in its own process: its environment names the agent that started it on stdio.
119
+ const callers = {
120
+ env: process.env,
121
+ client: () => server.server.getClientVersion(),
122
+ connectionId: randomUUID(),
123
+ };
116
124
  const runner = (writing) => async ({ command: asked, arguments: given }, ctx) => {
117
125
  try {
118
126
  const command = byNormal.get(normal(asked));
@@ -125,14 +133,15 @@ isMessenger) => {
125
133
  if (one.writes !== writing)
126
134
  throw new CliError("validation_error", `"${command}" ${writing ? "only reads" : "writes"}: call it with ${prefix}_${writing ? "read" : "write"}`);
127
135
  const args = writing ? agentArguments(given ?? {}) : (given ?? {});
136
+ const caller = currentCaller() ?? callerOf(ctx, command.replace(/[ -]/g, "_"), callers);
128
137
  if (one.checked)
129
- return runnableOnly(await one.run(args, ctx), (named) => byNormal.has(normal(named)));
138
+ return runnableOnly(await withCaller(caller, () => one.run(args, ctx)), (named) => byNormal.has(normal(named)));
130
139
  const parsed = v.safeParse(v.strictObject(one.input.entries), args);
131
140
  if (!parsed.success)
132
141
  throw new CliError("validation_error", `${command}: ${v.summarize(parsed.issues)}`, {
133
142
  arguments: toJsonSchema(one.input),
134
143
  });
135
- return runnableOnly(await one.run(parsed.output, ctx), (named) => byNormal.has(normal(named)));
144
+ return runnableOnly(await withCaller(caller, () => one.run(parsed.output, ctx)), (named) => byNormal.has(normal(named)));
136
145
  }
137
146
  catch (error) {
138
147
  return failed(error, errors);
@@ -13,6 +13,7 @@ import type { SendGuard } from "../sends/guard.js";
13
13
  import { type PermissionKey } from "../sends/permissions.js";
14
14
  import { type Services } from "../services/index.js";
15
15
  import type { AccountKey, MessageStore } from "../store/store.js";
16
+ import { type CallerSource } from "./caller.js";
16
17
  import type { MessengerSession } from "./session.js";
17
18
  export declare const limit: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 1, undefined>, v.MaxValueAction<number, 100, undefined>, v.DescriptionAction<number, "how many">]>, undefined>;
18
19
  export declare const page: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 1, undefined>, v.DescriptionAction<number, "which page, from 1">]>, undefined>;
@@ -34,7 +35,7 @@ export interface Defaults {
34
35
  limit: number;
35
36
  guard: SendGuard;
36
37
  /** The profile's own entries, for a tool reading a setting of its own — `transcribeWith`. */
37
- settings: Pick<Settings, "configured" | "shared" | "profile"> & Partial<Pick<Settings, "permissions" | "searchCatchUp" | "pictureSearch" | "searchReranker" | "offline">> & AISettings;
38
+ settings: Pick<Settings, "configured" | "shared" | "profile"> & Partial<Pick<Settings, "permissions" | "searchCatchUp" | "pictureSearch" | "searchReranker" | "offline" | "record" | "keepFailedRuns" | "keepRunsForDays" | "keepSuccessfulRunsForDays" | "runsSizeLimitMb">> & AISettings;
38
39
  env: NodeJS.ProcessEnv;
39
40
  keyring?: KeyringStore;
40
41
  /** The server's open models, kept between `search_conversations` calls. */
@@ -123,6 +124,8 @@ export interface Registration {
123
124
  log?: (call: ToolCall) => Promise<void>;
124
125
  /** The CLI's commands, by which an answer's next steps are judged safe to run unasked. */
125
126
  targets?: NonNullable<StepInput["targets"]>;
127
+ /** How a call's caller is worked out on this connection (`callerOf`); without it the environment's agent is kept. */
128
+ callers?: CallerSource;
126
129
  }
127
130
  export type ToolTier = "read" | "draft" | "write-private" | "write-public" | "destructive" | "admin";
128
131
  export interface ToolCall {
package/dist/mcp/tool.js CHANGED
@@ -9,13 +9,14 @@ import { MAX_BUFFERED_INPUT } from "../cli/input-policy.js";
9
9
  import { refuseLacking } from "../cli/messenger/lacks.js";
10
10
  import { ENTITIES } from "../entities/index.js";
11
11
  import { failureContext } from "../errors/reasons.js";
12
- import { partialOutcome, startRecording } from "../runs/recording.js";
12
+ import { startRecording } from "../runs/recording.js";
13
13
  import { newRunId } from "../runs/run.js";
14
- import { runContextOf } from "../runs/run-context.js";
14
+ import { keepsSuccess, runContextOf } from "../runs/run-context.js";
15
15
  import { inRun } from "../runs/scope.js";
16
16
  import { keyForCommand, levelFor, readKeysForCommand } from "../sends/permissions.js";
17
17
  import { onlineDeps, storeModeDeps } from "../services/deps.js";
18
18
  import { servicesFor } from "../services/index.js";
19
+ import { callerOf, currentCaller, withCaller } from "./caller.js";
19
20
  import { agentJson } from "./text.js";
20
21
  export const limit = v.optional(v.pipe(v.number(), v.integer(), v.minValue(1), v.maxValue(100), v.description("how many")));
21
22
  export const page = v.optional(v.pipe(v.number(), v.integer(), v.minValue(1), v.description("which page, from 1")));
@@ -104,19 +105,25 @@ export const entryRunner = (registration) => {
104
105
  const command = `mcp ${commandOf(key, definition)}`;
105
106
  const traceId = traceIdOf(ctx?.mcpReq?._meta?.traceparent);
106
107
  const app = registration.messenger?.app;
107
- const recording = key !== "runs_search" && registration.defaults.history !== false && app?.appName
108
+ const caller = registration.callers ? callerOf(ctx, key, registration.callers) : currentCaller();
109
+ const env = registration.defaults.env ?? process.env;
110
+ const settings = registration.defaults.settings;
111
+ const recording = key !== "runs_search" && settings.keepFailedRuns !== false && app?.appName
108
112
  ? startRecording({
109
113
  app,
110
114
  command,
111
- ...(registration.defaults.settings.profile === undefined
112
- ? {}
113
- : { profile: registration.defaults.settings.profile }),
114
- env: registration.defaults.env,
115
- record: false,
115
+ ...(settings.profile === undefined ? {} : { profile: settings.profile }),
116
+ env,
117
+ record: keepsSuccess({ record: settings.record ?? true, fromFlag: false, env, caller }),
116
118
  keepFailed: true,
117
119
  trace: false,
118
120
  format: "json",
119
- context: runContextOf({ bin: app.command, command, env: registration.defaults.env ?? process.env }),
121
+ context: runContextOf({ bin: app.command, command, env, caller }),
122
+ ...(settings.keepRunsForDays === undefined ? {} : { keepDays: settings.keepRunsForDays }),
123
+ ...(settings.keepSuccessfulRunsForDays === undefined
124
+ ? {}
125
+ : { keepSuccessDays: settings.keepSuccessfulRunsForDays }),
126
+ ...(settings.runsSizeLimitMb === undefined ? {} : { maxBytes: settings.runsSizeLimitMb * 1024 * 1024 }),
120
127
  ...(traceId === undefined ? {} : { traceId }),
121
128
  })
122
129
  : undefined;
@@ -127,8 +134,9 @@ export const entryRunner = (registration) => {
127
134
  startedAt,
128
135
  ...(profile === undefined ? {} : { profile }),
129
136
  ...(traceId === undefined ? {} : { traceId }),
137
+ ...(recording ? { events: recording.events } : {}),
130
138
  };
131
- const result = await inRun(scope, () => {
139
+ const work = () => inRun(scope, () => {
132
140
  const input = inputOf(definition, syncAllowedFor(key, registration.defaults));
133
141
  const parsed = v.safeParse(input, args);
134
142
  return parsed.success
@@ -137,7 +145,9 @@ export const entryRunner = (registration) => {
137
145
  arguments: toJsonSchema(input),
138
146
  }), app?.errors);
139
147
  });
140
- if (recording && (result.isError || partialOutcome(result.structuredContent))) {
148
+ // The connection a session opens for this call is recorded under the same caller.
149
+ const result = await (caller ? withCaller(caller, work) : work());
150
+ if (recording) {
141
151
  if (result.isError) {
142
152
  const error = result.structuredContent?.error;
143
153
  const code = error?.code && errorCodes.includes(error.code)
@@ -2,10 +2,12 @@
2
2
  * One operation against the messenger, as both sinks see it: `--trace` renders it, a recorded run
3
3
  * writes it as a line of JSON.
4
4
  *
5
- * ⚠ **Nothing here may carry content.** An operation name, an id, a count, a duration and an error
6
- * code are safe. A chat title, a name, a message body, a phone number or a token are not — not
7
- * truncated and not hashed. The adapter builds `ids` and `counts` from fields it names, never by
8
- * copying a request, and never from what the person typed: a typed chat is often a title.
5
+ * ⚠ **Nothing here may carry content.** An operation name, an id, a file or folder path, a count, a duration and an
6
+ * error code are safe: a run record stays on the owner's machine, and keeping the ids that tell runs apart was the
7
+ * owner's choice (NEED-110, 2026-10-27). A chat title, a name, a message body, a phone number, a token, a URL as sent
8
+ * or a query string are not — not truncated and not hashed. The adapter builds `ids` and `counts` from fields it names,
9
+ * never by copying a request, and never from what the person typed: a typed chat is often a title. Anything that
10
+ * leaves the machine (a bug report) strips the ids too.
9
11
  */
10
12
  export interface RequestEvent {
11
13
  event: "request" | "response";
@@ -30,6 +32,16 @@ export interface RequestEvent {
30
32
  errorCode?: string;
31
33
  /** The messenger's own name for a refusal, when it is shaped like one (`providerErrorKey`). */
32
34
  providerError?: string;
35
+ /**
36
+ * `operation` for one adapter call (`observed`), `wire` for one call on the network under it. A run counts wire
37
+ * requests when it has any, so one operation over three frames is three requests, never four.
38
+ */
39
+ level?: "operation" | "wire";
40
+ transport?: "http" | "mtproto" | "max-ws" | "wa-engine";
41
+ /** 1 for the first try, 2 and up for a retry of the same call. */
42
+ attempt?: number;
43
+ /** How long the client waited before this attempt: a flood wait or a `Retry-After`. */
44
+ waitMs?: number;
33
45
  }
34
46
  /** Something the client noticed and went on without. A code, never the sentence. */
35
47
  export interface WarningEvent {
@@ -28,6 +28,10 @@ export const renderEvent = (event) => {
28
28
  parts.push([event.method, event.endpointTemplate].filter(Boolean).join(" "));
29
29
  if (event.status !== undefined)
30
30
  parts.push(`${event.status}`);
31
+ if (event.attempt !== undefined && event.attempt > 1)
32
+ parts.push(`attempt ${event.attempt}`);
33
+ if (event.waitMs !== undefined)
34
+ parts.push(`waited ${age(event.waitMs)}`);
31
35
  }
32
36
  if (event.ids)
33
37
  for (const [name, value] of Object.entries(event.ids))
@@ -16,6 +16,10 @@ const diagnostic = {
16
16
  outcome: true,
17
17
  errorCode: true,
18
18
  providerError: true,
19
+ level: true,
20
+ transport: true,
21
+ attempt: true,
22
+ waitMs: true,
19
23
  code: true,
20
24
  detail: true,
21
25
  reason: true,
@@ -63,12 +67,14 @@ const background = [
63
67
  ];
64
68
  /** A crash: where it happened, never what it said. */
65
69
  const crash = ["errorClass", "frames"];
70
+ /** What a capped run did not write: how many, by operation. */
71
+ const dropped = ["dropped"];
66
72
  /**
67
73
  * Every attribute key cli-core's writers log, as written: snake_case (`logAttributes`). `createRecordLog`
68
74
  * blanks any other, cli-base's own camelCase list included.
69
75
  */
70
76
  export const LOG_ATTRIBUTES = [
71
- ...new Set([...Object.keys(diagnostic), ...Object.keys(send), ...background, ...crash].map((key) => Object.keys(logAttributes({ [key]: true }))[0])),
77
+ ...new Set([...Object.keys(diagnostic), ...Object.keys(send), ...background, ...crash, ...dropped].map((key) => Object.keys(logAttributes({ [key]: true }))[0])),
72
78
  ].sort();
73
79
  /** For `cli-dev lint --schemas`, under the name `log_attributes`. */
74
80
  export const logAttributesSchema = () => logAttributesJsonSchema(LOG_ATTRIBUTES);
@@ -10,6 +10,11 @@ export const CORE_LOG_EVENTS = defineLogEvents([
10
10
  keys: ["operation", "reason"],
11
11
  },
12
12
  { id: "command.crashed", meaning: "a command failed on a bug; where, never what", keys: ["error_class", "frames"] },
13
+ {
14
+ id: "events.dropped",
15
+ meaning: "the run reached its cap and counted the rest instead of writing them",
16
+ keys: ["dropped", "counts"],
17
+ },
13
18
  { id: "send.recorded", meaning: "a write the send guard saw, and how it ended", keys: ["outcome"] },
14
19
  { id: "job.start", meaning: "a background job started", keys: ["job"] },
15
20
  { id: "job.exit", meaning: "a background job ended", keys: ["job", "outcome"] },
@@ -18,6 +18,8 @@ export interface RecordingOptions {
18
18
  env?: NodeJS.ProcessEnv;
19
19
  runsDir?: string;
20
20
  keepDays?: number;
21
+ keepSuccessDays?: number;
22
+ maxBytes?: number;
21
23
  now?: () => Date;
22
24
  /** The caller's trace; otherwise the enclosing run's, otherwise `TRACEPARENT` from `env`. */
23
25
  traceId?: string;
@@ -27,13 +29,15 @@ export interface RecordingOptions {
27
29
  */
28
30
  processRun?: boolean;
29
31
  /** The whole command, its masked arguments and the agent that started it. */
30
- context?: RunContext;
32
+ context?: RunContext & {
33
+ argvIfFailed?: string[];
34
+ };
31
35
  }
32
36
  /**
33
37
  * Runs one command with the diagnostics turned on, and **finalizes on every path**.
34
38
  *
35
- * `--trace` shows each event as it happens and keeps nothing, `--record` keeps it and shows
36
- * nothing. It is a wrapper rather than three lines in each command because the failure it prevents
39
+ * `--trace` shows each event as it happens; the run is kept as the config says — every run unless
40
+ * `record: false`, which keeps failures only. It is a wrapper rather than three lines in each command because the failure it prevents
37
41
  * is the quiet one: a run directory left saying `running` because the command threw on the way out.
38
42
  */
39
43
  export declare const recorded: <T>(options: RecordingOptions, body: (events: EventSink) => Promise<T>) => Promise<T>;
@@ -49,6 +53,9 @@ export interface Recording {
49
53
  /** `recorded` in two halves, for a caller that starts a run before its command and ends it after (max-cli's `max bot`). */
50
54
  export declare const startRecording: (options: RecordingOptions) => Recording;
51
55
  export declare const wasSettled: (error: unknown) => boolean;
56
+ /** A run of a loop that never ends must not fill the disk: past these, events are counted by operation instead. */
57
+ export declare const EVENTS_AT_MOST = 2000;
58
+ export declare const EVENT_BYTES_AT_MOST: number;
52
59
  /**
53
60
  * Where a crash happened, not what it said: the class and up to ten frames as `function file:line`,
54
61
  * paths cut to the package's own (`framesOf`). **Never `message`** in an event — it can hold whatever the