@wardby/cli 0.8.0 → 0.9.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 (123) hide show
  1. package/deploy/local/docker-compose.quickstart-coding.yml +5 -0
  2. package/dist/claude-plugin/command.d.ts +4 -1
  3. package/dist/claude-plugin/command.js +7 -4
  4. package/dist/cli.js +24 -0
  5. package/dist/coding/transcript/recorder.js +2 -8
  6. package/dist/core/dispatch.js +3 -0
  7. package/dist/core/engine-native.d.ts +2 -0
  8. package/dist/core/engine-native.js +13 -0
  9. package/dist/core/error-code.js +1 -0
  10. package/dist/core/listen-address.d.ts +10 -0
  11. package/dist/core/listen-address.js +14 -0
  12. package/dist/core/power-idle.d.ts +8 -2
  13. package/dist/core/power-idle.js +17 -3
  14. package/dist/core/power.d.ts +5 -0
  15. package/dist/core/power.js +5 -0
  16. package/dist/core/retention-days.d.ts +5 -0
  17. package/dist/core/retention-days.js +14 -0
  18. package/dist/core/run-steps/config.d.ts +25 -0
  19. package/dist/core/run-steps/config.js +40 -0
  20. package/dist/core/run-steps/control-chars.d.ts +16 -0
  21. package/dist/core/run-steps/control-chars.js +26 -0
  22. package/dist/core/run-steps/detail.d.ts +32 -0
  23. package/dist/core/run-steps/detail.js +240 -0
  24. package/dist/core/run-steps/extract.d.ts +52 -0
  25. package/dist/core/run-steps/extract.js +467 -0
  26. package/dist/core/run-steps/ingress.d.ts +51 -0
  27. package/dist/core/run-steps/ingress.js +160 -0
  28. package/dist/core/run-steps/last-step.d.ts +15 -0
  29. package/dist/core/run-steps/last-step.js +35 -0
  30. package/dist/core/run-steps/native.d.ts +48 -0
  31. package/dist/core/run-steps/native.js +164 -0
  32. package/dist/core/run-steps/seen.d.ts +16 -0
  33. package/dist/core/run-steps/seen.js +35 -0
  34. package/dist/core/run-steps/summarize.d.ts +57 -0
  35. package/dist/core/run-steps/summarize.js +304 -0
  36. package/dist/core/run-steps/sweep.d.ts +21 -0
  37. package/dist/core/run-steps/sweep.js +41 -0
  38. package/dist/core/run-steps/types.d.ts +89 -0
  39. package/dist/core/run-steps/types.js +39 -0
  40. package/dist/core/run-steps/wire.d.ts +35 -0
  41. package/dist/core/run-steps/wire.js +47 -0
  42. package/dist/core/run-steps/writer.d.ts +49 -0
  43. package/dist/core/run-steps/writer.js +279 -0
  44. package/dist/core/runner.d.ts +4 -0
  45. package/dist/core/runner.js +15 -1
  46. package/dist/generated/prisma/browser.d.ts +8 -0
  47. package/dist/generated/prisma/client.d.ts +8 -0
  48. package/dist/generated/prisma/commonInputTypes.d.ts +30 -0
  49. package/dist/generated/prisma/enums.d.ts +6 -0
  50. package/dist/generated/prisma/enums.js +5 -0
  51. package/dist/generated/prisma/internal/class.d.ts +11 -0
  52. package/dist/generated/prisma/internal/class.js +4 -4
  53. package/dist/generated/prisma/internal/prismaNamespace.d.ts +103 -1
  54. package/dist/generated/prisma/internal/prismaNamespace.js +19 -1
  55. package/dist/generated/prisma/internal/prismaNamespaceBrowser.d.ts +19 -0
  56. package/dist/generated/prisma/internal/prismaNamespaceBrowser.js +19 -1
  57. package/dist/generated/prisma/models/Agent.d.ts +101 -1
  58. package/dist/generated/prisma/models/CodingProxySession.d.ts +41 -1
  59. package/dist/generated/prisma/models/Run.d.ts +503 -1
  60. package/dist/generated/prisma/models/RunStep.d.ts +1453 -0
  61. package/dist/generated/prisma/models/RunStep.js +1 -0
  62. package/dist/generated/prisma/models.d.ts +1 -0
  63. package/dist/help-index.json +102 -14
  64. package/dist/mcp/auth/access.d.ts +1 -0
  65. package/dist/mcp/context.d.ts +3 -0
  66. package/dist/mcp/index.js +10 -0
  67. package/dist/mcp/tools/agents.js +37 -7
  68. package/dist/mcp/tools/power.js +2 -2
  69. package/dist/mcp/tools/run-access.d.ts +7 -0
  70. package/dist/mcp/tools/run-access.js +14 -0
  71. package/dist/mcp/tools/run-steps.d.ts +16 -0
  72. package/dist/mcp/tools/run-steps.js +85 -0
  73. package/dist/mcp/tools/runs.js +10 -2
  74. package/dist/native-worker/gateway-server.d.ts +4 -1
  75. package/dist/native-worker/gateway-server.js +10 -2
  76. package/dist/native-worker/gateway.d.ts +3 -0
  77. package/dist/native-worker/gateway.js +8 -0
  78. package/dist/native-worker/http-server.d.ts +2 -4
  79. package/dist/native-worker/http-server.js +2 -8
  80. package/dist/providers/coding-proxy/memory-ledger.js +1 -0
  81. package/dist/providers/coding-proxy/prisma-ledger.js +9 -3
  82. package/dist/providers/coding-proxy/proxy.d.ts +26 -0
  83. package/dist/providers/coding-proxy/proxy.js +133 -42
  84. package/dist/providers/coding-proxy/run-step-forwarder.d.ts +42 -0
  85. package/dist/providers/coding-proxy/run-step-forwarder.js +206 -0
  86. package/dist/providers/coding-proxy/runtime.d.ts +7 -0
  87. package/dist/providers/coding-proxy/runtime.js +55 -3
  88. package/dist/providers/coding-proxy/types.d.ts +5 -0
  89. package/dist/providers/engine/types.d.ts +26 -0
  90. package/dist/providers/executor/container.d.ts +14 -0
  91. package/dist/providers/executor/container.js +8 -1
  92. package/dist/providers/jobs/kubernetes.d.ts +8 -0
  93. package/dist/providers/jobs/kubernetes.js +22 -4
  94. package/dist/quickstart-images.json +1 -1
  95. package/dist/serve.js +26 -18
  96. package/dist/viewer/api-schema.d.ts +66 -0
  97. package/dist/viewer/api-schema.js +31 -0
  98. package/dist/viewer/openapi.js +2 -1
  99. package/dist/viewer/run-detail.d.ts +2 -0
  100. package/dist/viewer/run-detail.js +26 -1
  101. package/docs/claude-code-plugin.md +57 -13
  102. package/docs/coding-agent-setup.md +17 -0
  103. package/docs/coding-worker-isolation.md +16 -0
  104. package/docs/debugging-agent-runs.md +112 -0
  105. package/docs/getting-started-gke.md +28 -7
  106. package/docs/mcp-servers.md +9 -0
  107. package/docs/native-sandbox.md +6 -0
  108. package/docs/security-deployment.md +51 -0
  109. package/docs/viewer-api.md +21 -12
  110. package/docs/viewer-api.openapi.yml +78 -2
  111. package/help/admin-viewer.md +1 -0
  112. package/help/claude-code-run-pane.md +27 -4
  113. package/help/deploy-gke.md +5 -0
  114. package/help/errors/coding-mcp-gateway-unavailable.md +46 -0
  115. package/help/low-power-mode.md +19 -8
  116. package/help/mcp.md +2 -0
  117. package/help/resume-runs-locally.md +4 -0
  118. package/help/run-steps.md +140 -0
  119. package/package.json +1 -1
  120. package/prisma/migrations/20261110000000_run_steps/migration.sql +26 -0
  121. package/prisma/migrations/20261111000000_run_steps_viewer_notify/migration.sql +59 -0
  122. package/prisma/migrations/20261112000000_run_steps_detail/migration.sql +9 -0
  123. package/prisma/schema.prisma +52 -0
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import { Prisma } from "#prisma";
9
9
  import { z } from "zod";
10
+ import { RUN_STEPS_LEVELS, loadRunStepsLevel, runStepsAboveCeilingWarning, } from "../../core/run-steps/config.js";
10
11
  import { codeProviderOf } from "../../coding/code-host.js";
11
12
  import { CodingProfilePatchSchema, CodingProfileSchema } from "../../coding/profile.js";
12
13
  import { DEFAULT_CLAUDE_MAX_OUTPUT_TOKENS, DEFAULT_CLAUDE_MAX_TURNS, MAX_CLAUDE_MAX_OUTPUT_TOKENS, MIN_CLAUDE_MAX_OUTPUT_TOKENS, DEFAULT_DEBUG_TRACE_MAX_MB, MAX_CODING_TURNS, MAX_DEBUG_TRACE_MAX_MB, isLocalRepository, } from "../../coding/protocol.js";
@@ -124,6 +125,27 @@ function withProfileWarnings(agent) {
124
125
  const warnings = profileWarnings(profile, now);
125
126
  return warnings.length ? { ...shown, warnings } : shown;
126
127
  }
128
+ // No JSON-schema enum on purpose: the zod check then refuses a bad value naming off, summary and detail.
129
+ const RUN_STEPS_DESCRIPTION = "Run steps level for this agent's runs (owner only): off records nothing, summary one line per tool call or message " +
130
+ "(the default), detail also the tool input, the end of each tool's output and the model's text, readable only by the " +
131
+ "agent's owner and the run's starter. A run records the lower of this and the instance's WARDBY_RUN_STEPS, fixed " +
132
+ "when it starts. detail stores tool output: secrets the run was given are removed, but a secret that exists only in " +
133
+ "a file the run read can remain. Null clears it (summary).";
134
+ /** For a warning only: an invalid WARDBY_RUN_STEPS stopped start-up already; never fails the tool call. */
135
+ function runStepsCeiling() {
136
+ try {
137
+ return loadRunStepsLevel(process.env);
138
+ }
139
+ catch {
140
+ return undefined;
141
+ }
142
+ }
143
+ /** An agent as returned to operators: a warning when its runSteps is above the instance's ceiling. */
144
+ function withRunStepsWarning(agent) {
145
+ const ceiling = runStepsCeiling();
146
+ const warning = ceiling ? runStepsAboveCeilingWarning(agent.runSteps, ceiling) : undefined;
147
+ return warning ? { ...agent, warnings: [...(agent.warnings ?? []), warning] } : agent;
148
+ }
127
149
  const agentFields = {
128
150
  name: z.string().trim().min(1).max(MAX_AGENT_NAME_CHARS),
129
151
  systemPrompt: z.string().min(1).max(MAX_SYSTEM_PROMPT_CHARS),
@@ -140,6 +162,7 @@ const agentFields = {
140
162
  budgetGroupId: z.string().min(1).max(128),
141
163
  memoryEnabled: z.boolean(),
142
164
  effort: z.enum(LLM_EFFORT_LEVELS),
165
+ runSteps: z.enum(RUN_STEPS_LEVELS),
143
166
  nativeExecutionMode: z.enum(NATIVE_EXECUTION_MODES),
144
167
  defectProjectKey: z.string().regex(PROJECT_KEY, "must be an upper-case Jira project key"),
145
168
  defectIssueType: z.string().trim().min(1).max(100),
@@ -175,6 +198,7 @@ const CreateAgentSchema = z
175
198
  budgetGroupId: agentFields.budgetGroupId.optional(),
176
199
  memoryEnabled: agentFields.memoryEnabled.default(false),
177
200
  effort: agentFields.effort.optional(),
201
+ runSteps: agentFields.runSteps.optional(),
178
202
  nativeExecutionMode: agentFields.nativeExecutionMode.optional(),
179
203
  defectProjectKey: agentFields.defectProjectKey.optional(),
180
204
  defectIssueType: agentFields.defectIssueType.optional(),
@@ -233,6 +257,7 @@ const UpdateAgentSchema = z
233
257
  budgetGroupId: agentFields.budgetGroupId.nullable().optional(),
234
258
  memoryEnabled: agentFields.memoryEnabled.optional(),
235
259
  effort: agentFields.effort.nullable().optional(),
260
+ runSteps: agentFields.runSteps.nullable().optional(),
236
261
  nativeExecutionMode: agentFields.nativeExecutionMode.optional(),
237
262
  defectProjectKey: agentFields.defectProjectKey.nullable().optional(),
238
263
  defectIssueType: agentFields.defectIssueType.nullable().optional(),
@@ -510,6 +535,7 @@ export function registerAgentTools(mcp) {
510
535
  enum: [...LLM_EFFORT_LEVELS],
511
536
  description: "Reasoning effort for native agents. Unset uses the provider default.",
512
537
  },
538
+ runSteps: { type: "string", description: RUN_STEPS_DESCRIPTION },
513
539
  nativeExecutionMode: {
514
540
  type: "string",
515
541
  enum: [...NATIVE_EXECUTION_MODES],
@@ -575,7 +601,7 @@ export function registerAgentTools(mcp) {
575
601
  },
576
602
  include: { codingProfile: true },
577
603
  });
578
- return textResult(withProfileWarnings(withOperatorMode(agent)));
604
+ return textResult(withRunStepsWarning(withProfileWarnings(withOperatorMode(agent))));
579
605
  },
580
606
  });
581
607
  mcp.registerTool({
@@ -616,6 +642,7 @@ export function registerAgentTools(mcp) {
616
642
  enum: [...LLM_EFFORT_LEVELS, null],
617
643
  description: "Reasoning effort for native agents. Null clears it back to the provider default.",
618
644
  },
645
+ runSteps: { type: ["string", "null"], description: RUN_STEPS_DESCRIPTION },
619
646
  nativeExecutionMode: {
620
647
  type: "string",
621
648
  enum: [...NATIVE_EXECUTION_MODES],
@@ -730,12 +757,14 @@ export function registerAgentTools(mcp) {
730
757
  // and budget-group membership direct what runs do with the owner's
731
758
  // repository access and money: owner-only, like the repository itself
732
759
  // (review I1/M1/M6). Write keeps name, prompt, model, budget amount,
733
- // turns, effort, memory and schedule.
760
+ // turns, effort, memory and schedule. The run steps level is the owner's too:
761
+ // detail records tool output that only the owner and the run's starter may read.
734
762
  const ownerOnlyChange = (existingKind) => args.codingProfile !== undefined ||
735
763
  (args.kind !== undefined && args.kind !== existingKind) ||
736
- args.budgetGroupId !== undefined;
764
+ args.budgetGroupId !== undefined ||
765
+ args.runSteps !== undefined;
737
766
  const refuseOwnerOnly = () => {
738
- throw new McpError(403, `Agent "${args.id}": only its owner can change its coding profile, kind or budget group.`);
767
+ throw new McpError(403, `Agent "${args.id}": only its owner can change its coding profile, kind, budget group or run steps level.`);
739
768
  };
740
769
  if (!adminEdit && !isOwner && ownerOnlyChange(before.kind))
741
770
  refuseOwnerOnly();
@@ -907,7 +936,7 @@ export function registerAgentTools(mcp) {
907
936
  by: ctx.principal.id,
908
937
  }, "coding package audit mode set");
909
938
  }
910
- const shown = withProfileWarnings(withOperatorMode(agent));
939
+ const shown = withRunStepsWarning(withProfileWarnings(withOperatorMode(agent)));
911
940
  // Becoming Codex (a provider switch, or a native agent becoming coding) leaves out every attached
912
941
  // MCP tool the server doesn't mark read-only: say which, so the owner isn't surprised by a run.
913
942
  const wasCodex = before.kind === "coding" && isCodexCoding(before.codingProfile?.provider);
@@ -924,7 +953,7 @@ export function registerAgentTools(mcp) {
924
953
  name: "list_agents",
925
954
  scope: "agents:read",
926
955
  description: "Lists the agents you can see. Each row has an `access` field, your level on that agent: owner > write > execute > read. When showing the agents as a table, include `access` as a column. " +
927
- "summary: true returns only id, name, kind, model, codingProvider, budgetUsd, ownerId and access per agent, for listings that don't need the full configuration. " +
956
+ "summary: true returns only id, name, kind, model, codingProvider, budgetUsd, ownerId, runSteps and access per agent, for listings that don't need the full configuration. " +
928
957
  "all: true lists every agent in the deployment (admins only, admin:view); an agent you have no grant on shows access `none`.",
929
958
  inputSchema: {
930
959
  type: "object",
@@ -948,6 +977,7 @@ export function registerAgentTools(mcp) {
948
977
  model: true,
949
978
  budgetUsd: true,
950
979
  ownerId: true,
980
+ runSteps: true,
951
981
  codingProfile: { select: { provider: true } },
952
982
  },
953
983
  orderBy: { name: "asc" },
@@ -980,7 +1010,7 @@ export function registerAgentTools(mcp) {
980
1010
  // A7: a tool's code and schema are its owner's, whoever can read the
981
1011
  // agent (the agent's owner included). Capabilities stay visible: config.
982
1012
  return textResult({
983
- ...withProfileWarnings(withOperatorMode(agent)),
1013
+ ...withRunStepsWarning(withProfileWarnings(withOperatorMode(agent))),
984
1014
  access,
985
1015
  mcpServers: await agentMcpServerViews(ctx.db, agent.id),
986
1016
  // Zod 3 window: the caller's own tools carry their warnings (see core/zod3-window.ts).
@@ -31,7 +31,7 @@ export function registerPowerTools(mcp) {
31
31
  mcp.registerTool({
32
32
  name: "power_status",
33
33
  scope: "power:manage",
34
- description: "Admins (power:manage): low-power mode state. For the coding proxy and the native gateway: replicas, whether a ready endpoint exists, when it last woke (wokeAt), and blockers (run ids keeping it busy). Returns { enabled: false } when low-power mode is off.",
34
+ description: "Admins (power:manage): low-power mode state. For the coding proxy and the native gateway: replicas, whether a ready endpoint exists, when it last woke (wokeAt), and blockers (run ids keeping it busy; for the gateway: sandbox runs, coding runs with MCP tools and runs with an active gateway session). Returns { enabled: false } when low-power mode is off.",
35
35
  inputSchema: { type: "object", additionalProperties: false, properties: {} },
36
36
  handler: async (rawArgs, ctx) => {
37
37
  parse(StatusSchema, "power_status arguments", rawArgs);
@@ -41,7 +41,7 @@ export function registerPowerTools(mcp) {
41
41
  mcp.registerTool({
42
42
  name: "power_sleep",
43
43
  scope: "power:manage",
44
- description: "Admins (power:manage): scale the coding proxy and/or native gateway to zero now, skipping low-power mode's idle window. It never stops a run: a target is refused while it has active or queued runs (for the gateway, also an active gateway session), and those run ids are returned under blocked. Recently finished runs do not block it. The next run wakes the target again. Fails (409 low_power_disabled) when low-power mode is off.",
44
+ description: "Admins (power:manage): scale the coding proxy and/or native gateway to zero now, skipping low-power mode's idle window. It never stops a run: a target is refused while it has active or queued runs (for the gateway: sandbox runs and coding runs with MCP tools, and also an active gateway session), and those run ids are returned under blocked. Recently finished runs do not block it. The next run wakes the target again. Fails (409 low_power_disabled) when low-power mode is off.",
45
45
  inputSchema: {
46
46
  type: "object",
47
47
  additionalProperties: false,
@@ -12,5 +12,12 @@ export interface RunReader {
12
12
  * agent's owner (or the stdio operator). Read on the agent is not enough.
13
13
  */
14
14
  export declare function canReadRun(db: GrantDb & Pick<PrismaClient, "agent">, run: Pick<Run, "agentId" | "triggeredById">, reader: RunReader): Promise<boolean>;
15
+ /**
16
+ * Detail text (the run steps' detail level): only the agent's owner, the run's starter, or the
17
+ * stdio operator. Today these are exactly canReadRun's readers (no grant reaches owner access, and
18
+ * run reads consult no admin role); this is defence in depth, so that a way to read runs added
19
+ * later does not reach detail text without its own decision.
20
+ */
21
+ export declare function canReadRunDetail(db: Pick<PrismaClient, "agent">, run: Pick<Run, "agentId" | "triggeredById">, reader: RunReader): Promise<boolean>;
15
22
  /** The run, when the caller may read it (canReadRun); otherwise the same 404 a missing run gets. */
16
23
  export declare function findReadableRun(ctx: McpRequestContext, runId: string): Promise<Run>;
@@ -13,6 +13,20 @@ export async function canReadRun(db, run, reader) {
13
13
  return false;
14
14
  return ((await effectiveAccess(db, "agent", agent, reader.principalId, { operator: reader.operator === true })) === "owner");
15
15
  }
16
+ /**
17
+ * Detail text (the run steps' detail level): only the agent's owner, the run's starter, or the
18
+ * stdio operator. Today these are exactly canReadRun's readers (no grant reaches owner access, and
19
+ * run reads consult no admin role); this is defence in depth, so that a way to read runs added
20
+ * later does not reach detail text without its own decision.
21
+ */
22
+ export async function canReadRunDetail(db, run, reader) {
23
+ if (reader.operator === true)
24
+ return true;
25
+ if (run.triggeredById !== null && run.triggeredById === reader.principalId)
26
+ return true;
27
+ const agent = await db.agent.findUnique({ where: { id: run.agentId }, select: { ownerId: true } });
28
+ return agent?.ownerId != null && agent.ownerId === reader.principalId;
29
+ }
16
30
  /** The run, when the caller may read it (canReadRun); otherwise the same 404 a missing run gets. */
17
31
  export async function findReadableRun(ctx, runId) {
18
32
  const run = await ctx.db.run.findUnique({ where: { id: runId } });
@@ -0,0 +1,16 @@
1
+ /**
2
+ * get_run_steps: what a run did, one line per step, oldest first. Same access as get_run. Steps
3
+ * are stored already redacted (src/core/run-steps/writer.ts) and returned as stored; a step's
4
+ * callId is never returned; its detail text (the detail level) only to the agent's owner, the run's
5
+ * starter and the stdio operator (canReadRunDetail).
6
+ */
7
+ import { type RunStepsLevel } from "../../core/run-steps/config.js";
8
+ import type { WardbyMcpServer } from "../server.js";
9
+ export declare const RUN_STEPS_DEFAULT_LIMIT = 50;
10
+ export declare const RUN_STEPS_MAX_LIMIT = 200;
11
+ export interface RunStepsToolOptions {
12
+ level: RunStepsLevel;
13
+ retentionDays: number;
14
+ now?: () => Date;
15
+ }
16
+ export declare function registerRunStepsTool(mcp: WardbyMcpServer, opts: RunStepsToolOptions): void;
@@ -0,0 +1,85 @@
1
+ /**
2
+ * get_run_steps: what a run did, one line per step, oldest first. Same access as get_run. Steps
3
+ * are stored already redacted (src/core/run-steps/writer.ts) and returned as stored; a step's
4
+ * callId is never returned; its detail text (the detail level) only to the agent's owner, the run's
5
+ * starter and the stdio operator (canReadRunDetail).
6
+ */
7
+ import { recordedRunStepsLevel } from "../../core/run-steps/config.js";
8
+ import { canReadRunDetail, findReadableRun } from "./run-access.js";
9
+ import { textResult } from "./text-result.js";
10
+ export const RUN_STEPS_DEFAULT_LIMIT = 50;
11
+ export const RUN_STEPS_MAX_LIMIT = 200;
12
+ const DAY_MS = 24 * 60 * 60 * 1000;
13
+ const EXPIRED_MEANING = "No steps are kept for this run: steps are deleted RUN_STEP_RETENTION_DAYS after they are recorded.";
14
+ export function registerRunStepsTool(mcp, opts) {
15
+ mcp.registerTool({
16
+ name: "get_run_steps",
17
+ scope: "agents:read",
18
+ description: "What a run did, one line per step, oldest first: each tool call (the tool and its command, file or first " +
19
+ "argument, cut short) with its status once known (ok, error or exit N), and what the model said, cut short. " +
20
+ "At the detail level (the instance's WARDBY_RUN_STEPS and the agent's runSteps both detail), the agent's owner " +
21
+ "and the run's starter also get each step's detail: the tool's input, the end of its output, or the model's " +
22
+ "text, redacted. Others get the lines only. level is the run's own, fixed when it started. " +
23
+ "To follow a running run, call again with after set to the last seq you have. " +
24
+ `next is the after for the next page, or null. limit defaults to ${RUN_STEPS_DEFAULT_LIMIT}, at most ` +
25
+ `${RUN_STEPS_MAX_LIMIT}. dropped counts steps past the per-run limit. Same access as get_run.`,
26
+ inputSchema: {
27
+ type: "object",
28
+ properties: {
29
+ runId: { type: "string" },
30
+ after: { type: "number", description: "Only steps with a larger seq" },
31
+ limit: {
32
+ type: "number",
33
+ description: `Steps to return (default ${RUN_STEPS_DEFAULT_LIMIT}, at most ${RUN_STEPS_MAX_LIMIT})`,
34
+ },
35
+ },
36
+ required: ["runId"],
37
+ },
38
+ handler: async (args, ctx) => {
39
+ const run = await findReadableRun(ctx, args.runId);
40
+ const showDetail = await canReadRunDetail(ctx.db, run, {
41
+ principalId: ctx.principal.id,
42
+ operator: ctx.operator,
43
+ });
44
+ const after = Number.isFinite(args.after) ? Math.max(0, Math.floor(args.after)) : 0;
45
+ const requested = Number.isFinite(args.limit) ? Math.floor(args.limit) : RUN_STEPS_DEFAULT_LIMIT;
46
+ const take = Math.min(Math.max(1, requested), RUN_STEPS_MAX_LIMIT);
47
+ const rows = await ctx.db.runStep.findMany({
48
+ where: { runId: run.id, seq: { gt: after } },
49
+ orderBy: { seq: "asc" },
50
+ take: take + 1,
51
+ select: {
52
+ seq: true,
53
+ at: true,
54
+ kind: true,
55
+ tool: true,
56
+ summary: true,
57
+ status: true,
58
+ ...(showDetail ? { detail: true } : {}),
59
+ },
60
+ });
61
+ const page = rows.slice(0, take);
62
+ const now = (opts.now ?? (() => new Date()))();
63
+ const expired = page.length === 0 &&
64
+ after === 0 &&
65
+ run.finishedAt != null &&
66
+ run.finishedAt.getTime() + opts.retentionDays * DAY_MS < now.getTime();
67
+ return textResult({
68
+ runId: run.id,
69
+ level: recordedRunStepsLevel(run.runStepsLevel, opts.level),
70
+ steps: page.map((s) => ({
71
+ seq: s.seq,
72
+ at: s.at.toISOString(),
73
+ kind: s.kind,
74
+ tool: s.tool,
75
+ summary: s.summary,
76
+ status: s.status,
77
+ ...(showDetail ? { detail: s.detail ?? null } : {}),
78
+ })),
79
+ next: rows.length > take ? page[page.length - 1].seq : null,
80
+ dropped: run.stepsDropped,
81
+ ...(expired ? { expired: true, meaning: EXPIRED_MEANING } : {}),
82
+ });
83
+ },
84
+ });
85
+ }
@@ -12,6 +12,7 @@ import { textResult } from "./text-result.js";
12
12
  import { loadBudgetEvents } from "../../core/budget-events.js";
13
13
  import { loadAgentSecretValues, maskNamedSecrets } from "../../core/secret-guard.js";
14
14
  import { logger } from "../../core/logger.js";
15
+ import { loadLastSteps } from "../../core/run-steps/last-step.js";
15
16
  const runsLog = logger.child({ module: "mcp-runs" });
16
17
  /**
17
18
  * Run visibility (resource-sharing grants spec §3.5, A6): a run's output is
@@ -126,6 +127,8 @@ async function listRunsAcrossAgents(ctx, filters, opts) {
126
127
  orderBy: { startedAt: "desc" },
127
128
  });
128
129
  const graph = await runGraphFields(ctx, runs.map((run) => run.id));
130
+ // The newest step of each run (the runs pane's one line), when it has any.
131
+ const lastSteps = await loadLastSteps(ctx.db, runs.map((run) => run.id));
129
132
  return runs.map((run) => {
130
133
  const agent = byId.get(run.agentId);
131
134
  return {
@@ -133,6 +136,7 @@ async function listRunsAcrossAgents(ctx, filters, opts) {
133
136
  agentName: agent?.name ?? null,
134
137
  agentBudgetUsd: agent ? Number(agent.budgetUsd) : null,
135
138
  ...(graph.get(run.id) ?? { triggerInfo: null, outcomes: [], serviceReadiness: [] }),
139
+ ...(lastSteps.has(run.id) ? { lastStep: lastSteps.get(run.id) } : {}),
136
140
  };
137
141
  });
138
142
  }
@@ -156,7 +160,7 @@ export function registerRunTools(mcp) {
156
160
  name: "list_runs",
157
161
  scope: "agents:read",
158
162
  description: "Lists runs, newest first. With agentId, that agent's runs; without it, runs across every agent you can see " +
159
- "(each row adds agentName, agentBudgetUsd, triggerInfo, outcomes and serviceReadiness, and leaves out finalText " +
163
+ "(each row adds agentName, agentBudgetUsd, triggerInfo, outcomes, serviceReadiness and lastStep (the run's newest step as one line, when it has steps; the agentId listing has no lastStep), and leaves out finalText " +
160
164
  `and pricingSnapshot; default limit ${ACROSS_AGENTS_DEFAULT_LIMIT}, ` +
161
165
  `at most ${ACROSS_AGENTS_MAX_LIMIT}). You see all runs of agents you own and only the runs you triggered on others. ` +
162
166
  "active: true keeps pending and running runs; since (ISO time) keeps runs that started or finished then or later, " +
@@ -208,7 +212,8 @@ export function registerRunTools(mcp) {
208
212
  scope: "agents:read",
209
213
  description: "One run in full: status, cost, request, result, errors and budget events, plus triggerInfo (what started it: " +
210
214
  "a pull request review or mention, an issue, a schedule or a lead run), outcomes (pull requests opened, comments, " +
211
- "checks) and serviceReadiness. A run's pull-request outcome matching another run's triggerInfo links the two.",
215
+ "checks) and serviceReadiness. A run's pull-request outcome matching another run's triggerInfo links the two. " +
216
+ "lastStep is the newest step; get_run_steps lists them all.",
212
217
  inputSchema: { type: "object", properties: { runId: { type: "string" } }, required: ["runId"] },
213
218
  handler: async (args, ctx) => {
214
219
  // Its triggerer keeps seeing it even after losing access to the agent;
@@ -279,12 +284,15 @@ export function registerRunTools(mcp) {
279
284
  const budgetEvents = await loadBudgetEvents(ctx.db, run.id);
280
285
  // What started the run and what it produced, as the admin viewer shows them.
281
286
  const graph = (await runGraphFields(ctx, [run.id])).get(run.id);
287
+ // The newest step (get_run_steps has them all).
288
+ const lastStep = (await loadLastSteps(ctx.db, [run.id])).get(run.id);
282
289
  // What the run was asked to do: the trigger's or lead's task, without the agent's standing instructions.
283
290
  const request = codingRun?.request ? await requestForReader(ctx, run, codingRun.request) : {};
284
291
  return textResult({
285
292
  ...withOperatorMode(run),
286
293
  ...request,
287
294
  ...(graph ?? {}),
295
+ ...(lastStep ? { lastStep } : {}),
288
296
  ...(budgetEvents.length > 0 ? { budgetEvents } : {}),
289
297
  ...(codingRun?.budgetReservedUsd != null ? { budgetReservedUsd: Number(codingRun.budgetReservedUsd) } : {}),
290
298
  ...(codingRun?.budgetBaseUsd != null ? { budgetBaseUsd: Number(codingRun.budgetBaseUsd) } : {}),
@@ -10,6 +10,7 @@
10
10
  * session budget before the provider is called and settles at the provider's
11
11
  * reported usage; the worker's own usage numbers are never read.
12
12
  */
13
+ import type { RunStepSink } from "../core/run-steps/types.js";
13
14
  import { createNativeRunTools, durableDelegate, type NativeRunFinishContext, type NativeRunProviders, type RunnerDb, type SandboxSessionSnapshot } from "../core/runner.js";
14
15
  import type { CodingMcpDeps } from "../core/coding-mcp.js";
15
16
  import { ReviewReadLedger } from "../core/review-reads.js";
@@ -66,7 +67,9 @@ interface RebuiltRun {
66
67
  delegation: Omit<Parameters<typeof durableDelegate>[0], "ledger" | "sessionId">;
67
68
  }
68
69
  /** Exported for its unit test; the HTTP handler is its only production caller. */
69
- export declare function streamModel(ctx: RebuiltRun, ledger: PrismaGatewayLedger, callId: string, params: GatewayParams<"llm.stream">): AsyncIterable<LlmStreamEvent>;
70
+ export declare function streamModel(ctx: RebuiltRun, ledger: PrismaGatewayLedger, callId: string, params: GatewayParams<"llm.stream">, steps?: RunStepSink,
71
+ /** The request's bearer capability, redacted from the recorded steps like a secret value. */
72
+ capability?: string): AsyncIterable<LlmStreamEvent>;
70
73
  /**
71
74
  * Logged (at warn) in place of a user tool's console line when that tool's redaction list cannot
72
75
  * be read: without the complete list the line cannot be shown to be free of its secret values, so
@@ -10,6 +10,7 @@
10
10
  * session budget before the provider is called and settles at the provider's
11
11
  * reported usage; the worker's own usage numbers are never read.
12
12
  */
13
+ import { NativeStreamSteps, recordStreamSteps } from "../core/run-steps/native.js";
13
14
  import { createNativeRunTools, durableDelegate, failNativeRun, finishNativeRun, nativeRunIntegrations, recordNativeRunProgress, runDrivability, sandboxCapabilityHash, userToolSecretValues, } from "../core/runner.js";
14
15
  import { ReviewReadLedger } from "../core/review-reads.js";
15
16
  import { DELEGATE_TOOL_PREFIX } from "../core/tool-names.js";
@@ -129,7 +130,7 @@ async function handle(deps, authorization, body, scope) {
129
130
  return {
130
131
  kind: "stream",
131
132
  status: 200,
132
- events: checkedEvents(streamModel(ctx, ledger, request.callId, params), scope.secretValues),
133
+ events: checkedEvents(streamModel(ctx, ledger, request.callId, params, deps.providers.runSteps, token), scope.secretValues),
133
134
  };
134
135
  case "llm.countTokens": {
135
136
  const p = params;
@@ -195,7 +196,9 @@ async function rebuild(deps, session) {
195
196
  };
196
197
  }
197
198
  /** Exported for its unit test; the HTTP handler is its only production caller. */
198
- export async function* streamModel(ctx, ledger, callId, params) {
199
+ export async function* streamModel(ctx, ledger, callId, params, steps,
200
+ /** The request's bearer capability, redacted from the recorded steps like a secret value. */
201
+ capability) {
199
202
  const req = params.request;
200
203
  if (req.model !== ctx.snapshot.input.agent.model)
201
204
  throw new GatewayError("not_allowed", "A run may only call its own model.");
@@ -210,6 +213,8 @@ export async function* streamModel(ctx, ledger, callId, params) {
210
213
  if (reserved.outcome === "budget_exhausted") {
211
214
  throw new GatewayError("budget_exhausted", "This run's budget cannot cover another model call.");
212
215
  }
216
+ // Run steps: what this call streams, and the results its request carries for the last turn.
217
+ const tap = steps ? new NativeStreamSteps(callId) : undefined;
213
218
  let settled = false;
214
219
  let sawEvent = false;
215
220
  // Set once the provider's response starts: it may bill hidden thinking or reasoning before any event.
@@ -238,6 +243,7 @@ export async function* streamModel(ctx, ledger, callId, params) {
238
243
  costUsd: event.usage.costUsd,
239
244
  });
240
245
  }
246
+ tap?.observe(event);
241
247
  yield event;
242
248
  }
243
249
  }
@@ -284,6 +290,8 @@ export async function* streamModel(ctx, ledger, callId, params) {
284
290
  costUsd: ctx.llm.priceUsd(req.model, { inputTokens, outputTokens: output }),
285
291
  });
286
292
  }
293
+ // Recorded before the stream ends (bounded), so the next call's results find these steps.
294
+ await recordStreamSteps(steps, ctx.session.runId, tap, req.messages, capability ? [capability] : []);
287
295
  }
288
296
  }
289
297
  /** Output tokens of a cut stream's output: counted, else the deliberately high bytes / 3 estimate. */
@@ -13,6 +13,7 @@
13
13
  import type { EngineResult, EngineRunContext } from "../providers/engine/types.js";
14
14
  import type { LlmStreamEvent } from "../providers/llm/types.js";
15
15
  import pino from "pino";
16
+ import type { RunStepSink } from "../core/run-steps/types.js";
16
17
  import { type PrivilegedHost } from "../sandbox/host-functions.js";
17
18
  import { logger } from "../core/logger.js";
18
19
  import { GatewayError, type WorkerInput } from "./protocol.js";
@@ -67,6 +68,8 @@ export interface SandboxedEngineOptions {
67
68
  mcpSecretValues?: () => Promise<string[]>;
68
69
  drivability: () => Promise<RunDrivability>;
69
70
  launcher: WorkerLauncher;
71
+ /** Records the run's steps (src/core/run-steps); absent = none. */
72
+ runSteps?: RunStepSink;
70
73
  }
71
74
  /** Runs one native run's engine in a worker and returns its result with gateway-metered usage. */
72
75
  export declare function runSandboxedEngine(options: SandboxedEngineOptions): Promise<EngineResult>;
@@ -11,6 +11,7 @@
11
11
  * sandbox bridges are served only by name from the run's own surface.
12
12
  */
13
13
  import pino from "pino";
14
+ import { NativeStreamSteps, recordStreamSteps } from "../core/run-steps/native.js";
14
15
  import { isPrivilegedBridgeName, redactExactSecretValues, withEscapedForms, } from "../sandbox/host-functions.js";
15
16
  import { logger } from "../core/logger.js";
16
17
  import { declaredCodeOf } from "../core/error-code.js";
@@ -134,6 +135,9 @@ export async function runSandboxedEngine(options) {
134
135
  const req = params.request;
135
136
  let sawDone = false;
136
137
  let streamedText = "";
138
+ // Run steps: what this call streams, and the results its request carries for the last turn.
139
+ const sink = options.runSteps;
140
+ const steps = sink ? new NativeStreamSteps(request.callId) : undefined;
137
141
  try {
138
142
  for await (const event of llm.stream(req, controller.signal)) {
139
143
  if (event.type === "done") {
@@ -143,6 +147,7 @@ export async function runSandboxedEngine(options) {
143
147
  else if (event.type === "text") {
144
148
  streamedText += event.delta;
145
149
  }
150
+ steps?.observe(event);
146
151
  yield event;
147
152
  }
148
153
  }
@@ -157,6 +162,9 @@ export async function runSandboxedEngine(options) {
157
162
  : 0;
158
163
  meter({ inputTokens, outputTokens, costUsd: llm.priceUsd(req.model, { inputTokens, outputTokens }) });
159
164
  }
165
+ // Recorded before the stream ends (bounded), so the next call's results find these steps.
166
+ // The run's capability (when the worker has one) is redacted like a secret value.
167
+ await recordStreamSteps(sink, runId, steps, req.messages, input.gateway ? [input.gateway.capability] : []);
160
168
  }
161
169
  },
162
170
  async call(raw) {
@@ -13,14 +13,12 @@
13
13
  * gateway capability. 404 when the listener was started without it.
14
14
  */
15
15
  import { type Server } from "node:http";
16
+ import { type ListenAddress } from "../core/listen-address.js";
16
17
  import { type GatewayServerDeps } from "./gateway-server.js";
17
18
  /** The HTTP listener's own log. Exported so tests can watch it. */
18
19
  export declare const httpLog: import("pino").Logger<never, boolean>;
19
20
  export declare const NATIVE_GATEWAY_PATH = "/native-gateway/v1/call";
20
- export interface NativeGatewayListen {
21
- host: string;
22
- port: number;
23
- }
21
+ export type NativeGatewayListen = ListenAddress;
24
22
  /** Parses NATIVE_GATEWAY_LISTEN ("host:port" or ":port"); undefined when unset. */
25
23
  export declare function parseGatewayListen(value: string | undefined): NativeGatewayListen | undefined;
26
24
  export declare function createGatewayServer(deps: GatewayServerDeps): Server;
@@ -14,6 +14,7 @@
14
14
  */
15
15
  import { createServer } from "node:http";
16
16
  import { CODING_MCP_PATH, handleCodingMcpRequest } from "../core/coding-mcp.js";
17
+ import { parseListenAddress } from "../core/listen-address.js";
17
18
  import { logger } from "../core/logger.js";
18
19
  import { HTTP_LIMITS, HttpBoundaryError, readBody as readLimitedBody } from "../mcp/transport/http-limits.js";
19
20
  import { logUnexpectedError } from "./gateway.js";
@@ -24,14 +25,7 @@ export const httpLog = logger.child({ module: "native-gateway-http" });
24
25
  export const NATIVE_GATEWAY_PATH = "/native-gateway/v1/call";
25
26
  /** Parses NATIVE_GATEWAY_LISTEN ("host:port" or ":port"); undefined when unset. */
26
27
  export function parseGatewayListen(value) {
27
- if (!value)
28
- return undefined;
29
- const match = /^(.*):(\d{1,5})$/.exec(value.trim());
30
- const port = match ? Number(match[2]) : NaN;
31
- if (!match || !Number.isInteger(port) || port < 1 || port > 65_535) {
32
- throw new Error(`NATIVE_GATEWAY_LISTEN must be host:port or :port (got "${value}").`);
33
- }
34
- return { host: match[1] || "0.0.0.0", port };
28
+ return parseListenAddress("NATIVE_GATEWAY_LISTEN", value);
35
29
  }
36
30
  function readBody(req) {
37
31
  return new Promise((resolve, reject) => {
@@ -49,6 +49,7 @@ export class MemoryProxyLedger {
49
49
  mcpTools: input.mcpTools ? structuredClone(input.mcpTools) : null,
50
50
  mcpTokenHash: input.mcpTokenHash ?? null,
51
51
  mcpCallCount: 0,
52
+ runStepsLevel: input.runStepsLevel ?? null,
52
53
  };
53
54
  this.sessions.set(session.id, session);
54
55
  this.sessionByCapability.set(session.capabilityHash, session.id);
@@ -1,3 +1,4 @@
1
+ import { RUN_STEPS_LEVELS } from "../../core/run-steps/config.js";
1
2
  import { logger } from "../../core/logger.js";
2
3
  import { normalizeProxyModelTerms } from "./types.js";
3
4
  const log = logger.child({ module: "coding-proxy-ledger" });
@@ -14,6 +15,9 @@ export const LEDGER_TRANSACTION_MAX_WAIT_MS = 5_000;
14
15
  * the model request. The row lock it holds is per session, so a longer
15
16
  * window only delays that run's own next request. */
16
17
  export const LEDGER_TRANSACTION_TIMEOUT_MS = 20_000;
18
+ function isRunStepsLevel(value) {
19
+ return typeof value === "string" && RUN_STEPS_LEVELS.includes(value);
20
+ }
17
21
  function sessionFromRow(row) {
18
22
  if (!Array.isArray(row.allowedModels) || !row.allowedModels.every((model) => typeof model === "string")) {
19
23
  throw new Error("invalid_proxy_session_models");
@@ -38,6 +42,7 @@ function sessionFromRow(row) {
38
42
  mcpTools: sessionMcpTools(row),
39
43
  mcpTokenHash: row.mcpTokenHash ?? null,
40
44
  mcpCallCount: Number(row.mcpCallCount ?? 0),
45
+ runStepsLevel: isRunStepsLevel(row.runStepsLevel) ? row.runStepsLevel : null,
41
46
  };
42
47
  }
43
48
  /** The session's pinned MCP tools; null when it has none. Anything but a list of pins refuses the session. */
@@ -127,20 +132,21 @@ export class PrismaProxyLedger {
127
132
  await this.db.$executeRaw `
128
133
  INSERT INTO "CodingProxySession"
129
134
  ("id", "runId", "capabilityHash", "credentialRef", "protocol", "allowedModels", "deadlineAt",
130
- "budgetUsd", "status", "registryTokenHash", "pricingVersion", "catalogEntry", "mcpTools", "mcpTokenHash",
135
+ "budgetUsd", "status", "registryTokenHash", "pricingVersion", "catalogEntry", "mcpTools", "mcpTokenHash", "runStepsLevel",
131
136
  "createdAt", "updatedAt")
132
137
  VALUES
133
138
  (${input.id}, ${input.runId}, ${input.capabilityHash}, ${input.credentialRef}, ${input.protocol}, ${models}::jsonb,
134
139
  ${input.deadlineAt}, ${input.budgetUsd}, 'active', ${input.registryTokenHash},
135
140
  ${terms?.version ?? null}, ${terms ? JSON.stringify(terms.entry) : null}::jsonb,
136
141
  ${input.mcpTools ? JSON.stringify(input.mcpTools) : null}::jsonb, ${input.mcpTokenHash ?? null},
142
+ ${input.runStepsLevel ?? null}::"RunStepsLevel",
137
143
  CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
138
144
  `;
139
145
  }
140
146
  async findSessionByCapabilityHash(capabilityHash) {
141
147
  const rows = await this.db.$queryRaw `
142
148
  SELECT "id", "runId", "capabilityHash", "credentialRef", "protocol", "allowedModels", "deadlineAt", "budgetUsd", "status", "registryTokenHash",
143
- "budgetExhaustedAt", "upstreamFailure", "pricingVersion", "catalogEntry", "mcpTools", "mcpTokenHash", "mcpCallCount"
149
+ "budgetExhaustedAt", "upstreamFailure", "pricingVersion", "catalogEntry", "mcpTools", "mcpTokenHash", "mcpCallCount", "runStepsLevel"
144
150
  FROM "CodingProxySession" WHERE "capabilityHash" = ${capabilityHash}
145
151
  `;
146
152
  return rows[0] ? sessionFromRow(rows[0]) : null;
@@ -148,7 +154,7 @@ export class PrismaProxyLedger {
148
154
  async findSessionByMcpTokenHash(mcpTokenHash) {
149
155
  const rows = await this.db.$queryRaw `
150
156
  SELECT "id", "runId", "capabilityHash", "credentialRef", "protocol", "allowedModels", "deadlineAt", "budgetUsd", "status", "registryTokenHash",
151
- "budgetExhaustedAt", "upstreamFailure", "pricingVersion", "catalogEntry", "mcpTools", "mcpTokenHash", "mcpCallCount"
157
+ "budgetExhaustedAt", "upstreamFailure", "pricingVersion", "catalogEntry", "mcpTools", "mcpTokenHash", "mcpCallCount", "runStepsLevel"
152
158
  FROM "CodingProxySession" WHERE "mcpTokenHash" = ${mcpTokenHash}
153
159
  `;
154
160
  return rows[0] ? sessionFromRow(rows[0]) : undefined;