shotops-mcp 0.9.0 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/local.js CHANGED
@@ -20,9 +20,11 @@ import { URL as NodeUrl, fileURLToPath } from "node:url";
20
20
  function studioOrigin() {
21
21
  return (process.env.STUDIO_ORIGIN || "https://shotops.dev").replace(/\/+$/, "");
22
22
  }
23
+ function authorityOrigin() {
24
+ return (process.env.SHOTOPS_AUTH_ORIGIN || "https://mcp.shotops.dev").replace(/\/+$/, "");
25
+ }
23
26
  function deployTarget() {
24
27
  if (process.env.VERCEL) return "vercel";
25
- if (process.env.FLY_APP_NAME) return "fly";
26
28
  return "local";
27
29
  }
28
30
  function maxInlineScreenshotBytes() {
@@ -890,13 +892,19 @@ var init_capabilities = __esm({
890
892
  });
891
893
 
892
894
  // src/authorization.ts
895
+ function planRequiredMessage(requiredPlan) {
896
+ if (requiredPlan && requiredPlan !== "pro") {
897
+ return `This action needs the ${requiredPlan} plan \u2014 upgrade at https://shotops.dev/app.`;
898
+ }
899
+ return "This action needs an active Pro trial or Pro. Start the free trial or upgrade at https://shotops.dev/app.";
900
+ }
893
901
  function authorizationMessage(decision) {
894
902
  if (decision.allowed) return null;
895
903
  switch (decision.reason) {
896
904
  case "authentication_required":
897
905
  return "Sign in to use this capability.";
898
906
  case "plan_required":
899
- return `This action requires the ${decision.requiredPlan ?? "required"} plan.`;
907
+ return planRequiredMessage(decision.requiredPlan);
900
908
  case "trial_choice_required":
901
909
  return "Your Pro trial has ended. Choose Pro or Free at https://shotops.dev/app before using this action.";
902
910
  case "limit_reached":
@@ -932,7 +940,7 @@ function capabilityDenialFromError(error) {
932
940
  function capabilityDenialMessage(denial) {
933
941
  switch (denial.reason) {
934
942
  case "plan_required":
935
- return `This action requires the ${denial.requiredPlan ?? "required"} plan.`;
943
+ return planRequiredMessage(denial.requiredPlan);
936
944
  case "trial_choice_required":
937
945
  return "Your Pro trial has ended. Choose Pro or Free at https://shotops.dev/app before using this action.";
938
946
  case "billing_unavailable":
@@ -1512,18 +1520,37 @@ ${list}${more}` : "";
1512
1520
  }
1513
1521
  return "No such project on this account." + offer;
1514
1522
  }
1523
+ function disclosureStatement(disclosure) {
1524
+ return `This call moves ${disclosure.moves}, because ${disclosure.why}. It is kept for ${disclosure.retentionDays} days and then removed. What retains it: ${disclosure.retainedBy}`;
1525
+ }
1515
1526
  function buildIdentityInstructions(version2) {
1516
1527
  return `This server is ShotOps build \`${version2}\` (package version, plus the deployed commit after the \`+\` when hosted) \u2014 quote it verbatim if asked what version of ShotOps this is, and say that nothing more precise is visible from here. A project\u2019s \`schema\` and a look\u2019s version number (v70, v71 \u2026) describe the USER\u2019S saved work and are never the answer to that question.`;
1517
1528
  }
1518
- var BROWSER_FIRST_ROUTE, SAVE_PROJECT_NEXT_STEP, SERVER_INSTRUCTIONS, ANONYMOUS_TOOLS;
1529
+ var BROWSER_FIRST_ROUTE, OFFER_SAVE_PROJECT_NEXT_STEP, ACCOUNT_UPLOAD_DISCLOSURE, PENDING_CLAIM_DISCLOSURE, SERVER_INSTRUCTIONS, ANONYMOUS_TOOLS;
1519
1530
  var init_toolContract = __esm({
1520
1531
  "src/toolContract.ts"() {
1521
1532
  "use strict";
1522
1533
  init_shotLookProjection();
1534
+ init_retentionPolicy();
1523
1535
  BROWSER_FIRST_ROUTE = `No file transfer is needed at all: the user can sign in at https://shotops.dev, drop the screenshots into a project, set that project's reach to "Your account", and you then render it from here with render_project({ project }) and NO screenshots \u2014 the project already holds them.`;
1524
- SAVE_PROJECT_NEXT_STEP = {
1536
+ OFFER_SAVE_PROJECT_NEXT_STEP = {
1537
+ action: "offer_save_project",
1525
1538
  tool: "save_project",
1526
- reason: "These panels are images only \u2014 render_strip saved nothing, so this design will not outlive the conversation. Call save_project with the same screenshots and NO `project` argument to create a new editable ShotOps project, then give the user the openUrl it returns. Do this even if they only asked for renders or a bundle. Skip it only if they explicitly said not to save a project, or name an existing project only if they explicitly said the work belongs there."
1539
+ projectCreated: false,
1540
+ consent: "required",
1541
+ reason: "No ShotOps project exists for this strip and nothing was written \u2014 these panels are images only, so the design behind them ends with the conversation. That is the correct default for a preview, not a problem to fix. OFFER a save when the user shows they want this work preserved, shared, reused or kept editable; only then call save_project with the same screenshots and NO `project` argument, and hand back the openUrl it returns. Asking for images, full-resolution panels or a bundle is not that intent, and neither is silence: if you cannot put the question to the user, return these panels with the offer standing rather than saving on their behalf. Name an existing project as `project` only when they said the work belongs there."
1542
+ };
1543
+ ACCOUNT_UPLOAD_DISCLOSURE = {
1544
+ moves: "the screenshot bytes you supply, into private storage scoped to this ShotOps account",
1545
+ why: "a render reads a stored ref instead of carrying full-resolution pixels through the conversation, and only an account-scoped ref can be re-rendered later",
1546
+ retentionDays: retentionDays(RETENTION_POLICY.hostedAssetTtlMs),
1547
+ retainedBy: "save_project \u2014 a saved project keeps every ref it references for as long as it references it. Nothing else retains an upload, and nothing is retained automatically."
1548
+ };
1549
+ PENDING_CLAIM_DISCLOSURE = {
1550
+ moves: "the raw PNGs read from this machine, together with the project record, into private pending-claim storage on the ShotOps server",
1551
+ why: "an unsigned local save has no account to file the project under, so the project can only reopen whole in a browser if its sources are staged alongside the record",
1552
+ retentionDays: retentionDays(RETENTION_POLICY.pendingProjectClaimTtlMs),
1553
+ retainedBy: "opening the returned openUrl and signing in within the window, which makes that person the owner and turns the staged copy into their project. Unclaimed, the record and the PNGs are swept."
1527
1554
  };
1528
1555
  SERVER_INSTRUCTIONS = [
1529
1556
  "ShotOps makes the marketing screenshots an app store listing needs. Give it the raw screen",
@@ -1585,18 +1612,17 @@ var init_toolContract = __esm({
1585
1612
  ' render_strip output:"urls" result as `panels` to package without re-rendering. On the LOCAL',
1586
1613
  " stdio tier (no account, so no upload refs) pass panels straight off disk instead:",
1587
1614
  ' panels: [{ "path": "/abs/panel-01.png" }, \u2026] \u2014 the same { path } local-only door as screenshots.',
1588
- "4. NEW work becomes a NEW editable ShotOps project by default: call save_project WITHOUT a",
1589
- " `project` argument, then give its `openUrl` to the user. A signed-in/token-backed call returns",
1590
- ' a projectId immediately; unsigned local stdio returns status:"pending_claim" with NO projectId,',
1591
- " and the user becomes its owner by opening the link and signing in within 7 days. Do this even",
1592
- " when they asked only",
1593
- " for renders or a bundle \u2014 do not merely offer. Skip project creation only when the user",
1594
- " explicitly says not to save/create a project. If they explicitly say the work belongs in an",
1595
- " existing project, pass that project as `project` so save_project updates it, and return that",
1596
- " `openUrl` instead. Never infer an update target from the most-recent project.",
1615
+ "4. PERSISTENCE IS A SEPARATE ASK. A render creates nothing and updates nothing \u2014 the result says",
1616
+ " so structurally (`contract.effects.projectMutation: false`), and a project-less render also",
1617
+ " carries a `nextStep` whose action is `offer_save_project`: an OFFER to relay, never consent to",
1618
+ " act on. Call save_project only once the user has shown they want this work preserved, shared,",
1619
+ " reused or kept editable. Then omit `project` to create a NEW project and hand back its",
1620
+ " `openUrl`; pass `project` only when they named an existing one. See the lifecycle section below.",
1597
1621
  "5. Hosted sources and private outputs expire after the retention window unless a Project or another live",
1598
- " resource retains them. After delivery, OFFER to clean up unreferenced artifacts by opaque id.",
1599
- " Call delete_assets({ assetIds: [...] }) only when the user explicitly asks; referenced assets are refused.",
1622
+ " resource retains them. After delivery, OFFER to clean up UNREFERENCED temporary artifacts by",
1623
+ " opaque id. Call delete_assets({ assetIds: [...] }) only when the user explicitly asks; anything a",
1624
+ " Project, operation, share or preview still depends on is refused \u2014 never propose deleting an",
1625
+ " asset a Project references, and never present cleanup as a step that has to happen.",
1600
1626
  "",
1601
1627
  "## Preview is open; STORE-READY OUTPUT needs an active Pro trial or Pro",
1602
1628
  "Two different things, and the split is the same on the hosted server and on `npx shotops-mcp`:",
@@ -1605,7 +1631,7 @@ var init_toolContract = __esm({
1605
1631
  "including a zip composed only from panels that were already rendered \u2014 needs a signed-in account",
1606
1632
  "on an active Pro trial or Pro. The refusal arrives BEFORE anything is rendered, written,",
1607
1633
  "uploaded or charged, so a denied call costs the user nothing and leaves no half-made files.",
1608
- "account_status answers this in one free call: no credits, no free-preview allowance, no uploads,",
1634
+ "account_status answers this in one free call: no cloud credits, no free-preview allowance, no uploads,",
1609
1635
  "no writes. Call it before starting a long full-resolution job you are not sure will be allowed,",
1610
1636
  "and relay its `nextStep` (sign_in, choose_plan, upgrade, retry) rather than retrying the refused",
1611
1637
  "call unchanged. Never guess at someone's plan and never claim a limit it did not report.",
@@ -1723,29 +1749,45 @@ var init_toolContract = __esm({
1723
1749
  "and heldVersion with a structured `history` denial; explicit historical renders use the latest",
1724
1750
  "saved Look instead. A retained Hold stays preserved and becomes active again after upgrading.",
1725
1751
  "",
1726
- // #213 — this section is ALSO carried structurally, as SAVE_PROJECT_NEXT_STEP above, which
1752
+ // This section is ALSO carried structurally, as OFFER_SAVE_PROJECT_NEXT_STEP above, which
1727
1753
  // render_strip attaches to every project-less render because some clients never deliver these
1728
1754
  // instructions. Edit the two together: prose that says one thing while the wire says another is
1729
1755
  // worse than either alone.
1730
- "## Save every new MCP creation as an editable project (the default)",
1731
- "A new strip made through MCP should outlive the conversation as a real ShotOps project the user",
1732
- "can open + refine. After creating the design, call save_project with NO `project` argument so it",
1733
- "creates a NEW project. It always returns an openUrl \u2014 hand it back to the user. Authenticated",
1734
- "calls also return projectId; unsigned local stdio returns an explicit pending_claim with no",
1735
- "invented id, and opening that URL + signing in within 7 days makes the user its owner. This is",
1736
- "the default even when the request only mentions",
1737
- 'renders, PNGs, or a bundle; do not wait for a separate "save it" request.',
1738
- "There are only two user-directed exceptions: (1) if they explicitly say not to create/save a",
1739
- "project, do not call save_project; (2) if they explicitly say the work belongs in an existing",
1740
- "project, thread that projectId/name back as `project` so save_project UPDATES it in place, then",
1741
- "return its openUrl. Never silently choose a recent project: no explicit target means NEW project.",
1742
- "save_project is FAST and never renders \u2014 it saves structure + look only. render_strip no longer",
1743
- "writes projects at all; emit_bundle still carries the older createProject flag for backward",
1744
- "compatibility, but new conversational flows should keep rendering and project writes separate.",
1745
- "Ordinary local render/export uploads nothing. Only an unsigned local save_project deliberately",
1746
- "uploads its local raw PNGs to temporary private claim storage so the claimed project reopens whole.",
1756
+ "## The lifecycle \u2014 preview by default, persistence on request",
1757
+ "Six steps, in order. Only the preview happens on its own; every other step needs something the",
1758
+ "user said.",
1759
+ "1. INSPECT when the input, the persistence or the cost is uncertain. account_status is free and",
1760
+ " says what this connection may do, what a preview costs here, and whether store-ready output is",
1761
+ " available at all. read_project says what a saved project already holds.",
1762
+ "2. PREVIEW, and create nothing. render_strip / render_project with `preview: true` shows the design",
1763
+ " without writing a project, updating a project, or asking anyone for permission beyond the",
1764
+ " request itself. This is the default visual step and the thing to reach for first.",
1765
+ "3. OFFER TO SAVE \u2014 only after the user expressed a PRESERVE, COLLABORATE, REUSE or CONTINUE-EDITING",
1766
+ ' intent ("keep this", "I want to tweak it later", "send it to my designer"). save_project with NO',
1767
+ " `project` argument then creates a NEW project and always returns an `openUrl` to hand back; a",
1768
+ " signed-in or token-backed call also returns a projectId, while unsigned local stdio returns",
1769
+ ' status:"pending_claim" with NO invented id and the user becomes owner by opening the link and',
1770
+ " signing in inside the claim window. If they said the work belongs in an EXISTING project, pass",
1771
+ " it as `project` and save_project updates that one in place. Never infer an update target from",
1772
+ " the most recently edited project, and never read a render, a bundle request or silence as",
1773
+ " agreement to save. If your client cannot put the question to the user at all, deliver the",
1774
+ " result and the offer \u2014 do not save for them.",
1775
+ "4. DISCLOSE BEFORE BYTES MOVE. Ordinary local rendering and exporting upload nothing. Three calls",
1776
+ " do move bytes into ShotOps storage \u2014 import_screenshot and request_screenshot_upload on the",
1777
+ " hosted server, and an UNSIGNED local save_project, which stages its raw local PNGs in private",
1778
+ " claim storage so the claimed project reopens whole. Before each, say what moves, why, how long",
1779
+ " it is kept and the explicit action that retains it. Each of those results carries the same four",
1780
+ " facts as a `disclosure` block; relay it rather than paraphrasing a retention window.",
1781
+ "5. PRODUCE FINAL only once entitlement and readiness allow it, carrying back the exact waiver",
1782
+ " receipts the server handed you. Refusals arrive before any charge, any pixel and any file.",
1783
+ "6. DELIVER, then OFFER cleanup of unreferenced temporary assets only. Never propose deleting",
1784
+ " something a project still points at, and never make cleanup automatic.",
1785
+ "save_project is FAST and never renders \u2014 structure + look only. render_strip does not write",
1786
+ "projects at all. emit_bundle still accepts the older `createProject` flag for backward",
1787
+ "compatibility: use it ONLY when the user explicitly asked for both the bundle and a saved project,",
1788
+ "and never infer it from a bundle request.",
1747
1789
  "",
1748
- "## IMPORTANT \u2014 make the saved strip re-openable",
1790
+ "## When you DO save, make the strip re-openable",
1749
1791
  "The record itself never stores screenshot BYTES (zero-custody). What it can store is a MANIFEST:",
1750
1792
  "when the screenshots you rendered with were account-scoped refs (from request_screenshot_upload",
1751
1793
  "or import_screenshot), saving the project records where those uploads live, and the user opens it",
@@ -4564,7 +4606,7 @@ var init_resultContract = __esm({
4564
4606
  "What this call DID to the world, independent of what it returned. Every flag is stated on every result, false included: an absent flag would be indistinguishable from an effect nobody thought to declare."
4565
4607
  );
4566
4608
  costSchema = z3.object({
4567
- unit: z3.literal("credit").describe("Public credits \u2014 the same unit every ShotOps surface quotes."),
4609
+ unit: z3.literal("credit").describe("Public cloud credits \u2014 the same unit every ShotOps surface quotes."),
4568
4610
  model: z3.enum(["metered", "anonymous_allowance", "unmetered"]).describe(
4569
4611
  "How this connection pays. `unmetered` is local stdio, which renders on the caller\u2019s own machine; `anonymous_allowance` is the unsigned hosted taste."
4570
4612
  ),
@@ -4573,7 +4615,7 @@ var init_resultContract = __esm({
4573
4615
  reserved: z3.number().optional().describe("Held against the wallet for the duration of the call (#666)."),
4574
4616
  released: z3.number().optional().describe("Given back \u2014 an unused reservation or a refund after a post-charge failure (#666)."),
4575
4617
  settled: z3.number().optional().describe("Actually taken. Absent when nothing was charged."),
4576
- balanceAfter: z3.number().optional().describe("The wallet\u2019s public credit balance once this call settled."),
4618
+ balanceAfter: z3.number().optional().describe("The wallet\u2019s public cloud credit balance once this call settled."),
4577
4619
  refillAt: z3.string().optional().describe("ISO 8601. When the wallet is next topped up; absent when none is scheduled.")
4578
4620
  }).passthrough();
4579
4621
  artifactSchema = z3.object({
@@ -4736,7 +4778,7 @@ var init_resultContract = __esm({
4736
4778
 
4737
4779
  // src/outputSchemas.ts
4738
4780
  import { z as z4 } from "zod";
4739
- var projectSummary, storedAssetOutput, panelDeliveryOutput, panelOutput, lookHistoryAccess, noteField, creditsField, layoutFindingOutput, layoutAdvisoryOutput, contractField, operationStateOutputSchema, durableRenderFinalOutputSchema, durableBundleFinalOutputSchema, renderStripOutputSchema, optionalShape, operationStateShape, renderStripToolOutputSchema, emitBundleOutputSchema, emitBundleToolOutputSchema, saveProjectOutputSchema, layoutFieldDiff, layoutReportShape, layoutReportOutput, readLookOutputSchema, storedScreenshots, readProjectOutputSchema, agentOutcomeOutput, refineProjectOutputSchema, renderProjectOutputSchema, renderProjectToolOutputSchema, productionOperationOutputSchema, saveLookOutputSchema, holdLookOutputSchema, releaseLookOutputSchema, describeLookOutputSchema, accountCreditBalanceSchema, accountStatusOutputSchema, requestScreenshotUploadOutputSchema, importedScreenshotOutput, importScreenshotOutputSchema, deleteAssetsOutputSchema;
4781
+ var projectSummary, storedAssetOutput, panelDeliveryOutput, panelOutput, lookHistoryAccess, noteField, creditsField, layoutFindingOutput, layoutAdvisoryOutput, contractField, disclosureField, operationStateOutputSchema, durableRenderFinalOutputSchema, durableBundleFinalOutputSchema, renderStripOutputSchema, optionalShape, operationStateShape, renderStripToolOutputSchema, emitBundleOutputSchema, emitBundleToolOutputSchema, saveProjectOutputSchema, layoutFieldDiff, layoutReportShape, layoutReportOutput, readLookOutputSchema, storedScreenshots, readProjectOutputSchema, agentOutcomeOutput, refineProjectOutputSchema, renderProjectOutputSchema, renderProjectToolOutputSchema, productionOperationOutputSchema, saveLookOutputSchema, holdLookOutputSchema, releaseLookOutputSchema, describeLookOutputSchema, accountCreditBalanceSchema, accountStatusOutputSchema, requestScreenshotUploadOutputSchema, importedScreenshotOutput, importScreenshotOutputSchema, deleteAssetsOutputSchema, advertisedWithFailure, refineProjectToolOutputSchema, saveProjectToolOutputSchema, readLookToolOutputSchema, readProjectToolOutputSchema, saveLookToolOutputSchema, holdLookToolOutputSchema, releaseLookToolOutputSchema, requestScreenshotUploadToolOutputSchema, importScreenshotToolOutputSchema, deleteAssetsToolOutputSchema;
4740
4782
  var init_outputSchemas = __esm({
4741
4783
  "src/outputSchemas.ts"() {
4742
4784
  "use strict";
@@ -4794,6 +4836,15 @@ var init_outputSchemas = __esm({
4794
4836
  "What is wrong with this render\u2019s COMPOSITION, measured on the panels it just returned. ABSENT means there is nothing to report \u2014 a clean strip, or one whose captions could not be measured. Advisory: the panels are already rendered and nothing here changed a pixel."
4795
4837
  );
4796
4838
  contractField = resultEnvelopeSchema.optional();
4839
+ disclosureField = z4.object({
4840
+ moves: z4.string().describe("The bytes that left the caller\u2019s control, in plain words."),
4841
+ why: z4.string().describe("What the move buys \u2014 never a restatement of the mechanism."),
4842
+ retentionDays: z4.number().int().positive().describe("How long ShotOps keeps it without an explicit retaining action. Read from the retention policy, never typed."),
4843
+ retainedBy: z4.string().describe("The explicit action that keeps it past that window."),
4844
+ statement: z4.string().describe("The same four facts as one sentence, safe to relay verbatim.")
4845
+ }).passthrough().optional().describe(
4846
+ "Stated because this call moved caller bytes into ShotOps storage. Relay it before or with the result; do not paraphrase the retention window."
4847
+ );
4797
4848
  operationStateOutputSchema = z4.object({
4798
4849
  ok: z4.boolean(),
4799
4850
  operationId: z4.string(),
@@ -4841,15 +4892,26 @@ var init_outputSchemas = __esm({
4841
4892
  renderMs: z4.number(),
4842
4893
  output: z4.enum(["inline", "urls"]),
4843
4894
  locale: z4.string(),
4844
- // #213 — the follow-up the contract has always required, carried in the output because that is
4845
- // the one channel every client delivers (SERVER_INSTRUCTIONS are truncated or dropped by some).
4846
- // Declared rather than left to .passthrough() so an agent reading the schema sees it exists.
4895
+ // The OFFER a project-less render carries, in the output because that is the one channel every
4896
+ // client delivers (SERVER_INSTRUCTIONS are truncated or dropped by some). Declared rather than
4897
+ // left to .passthrough() so an agent reading the schema sees it exists.
4898
+ //
4899
+ // `action` and `consent` are what keep it an offer. This field used to be a bare
4900
+ // `{ tool: 'save_project', reason }` whose prose told an agent to save unless the user opted
4901
+ // out — a render request read as consent to write a durable record. It is now typed as the
4902
+ // question it always should have been.
4903
+ //
4847
4904
  // OPTIONAL for the #196 reason: a render tied to an existing project omits it, and a declared
4848
4905
  // output field that a legitimate response omits must never be required — the SDK validates
4849
4906
  // structuredContent, so that would turn a SUCCESSFUL render into a validation error.
4850
4907
  // renderProjectOutputSchema extends this and never emits it; optional keeps that honest.
4851
4908
  nextStep: z4.object({
4852
- tool: z4.literal("save_project"),
4909
+ action: z4.literal("offer_save_project").describe("An offer to put to the user. Never a command, and never consent already given."),
4910
+ tool: z4.literal("save_project").describe("The door that would persist this work, if they ask for it."),
4911
+ projectCreated: z4.literal(false).describe("No project was created or updated by this call \u2014 the same fact as `contract.effects.projectMutation`."),
4912
+ consent: z4.literal("required").describe(
4913
+ "Saving needs the user\u2019s own preserve/reuse/keep-editing intent. A client that cannot ask returns this offer unresolved rather than saving."
4914
+ ),
4853
4915
  reason: z4.string()
4854
4916
  }).optional(),
4855
4917
  credits: creditsField,
@@ -4911,7 +4973,14 @@ var init_outputSchemas = __esm({
4911
4973
  // #224 — save_project used to DROP this entirely: it destructured `{ look, style }` out of
4912
4974
  // resolveLook and never forwarded the note, on the one tool where a wrong composition is
4913
4975
  // PERSISTED rather than merely rendered.
4914
- note: noteField
4976
+ note: noteField,
4977
+ // Only on the unsigned local pending-claim path, the one save that stages raw PNGs off this
4978
+ // machine. A signed save moves no bytes and states nothing.
4979
+ disclosure: disclosureField,
4980
+ // #662 — the effects of the one call in this contract whose job IS a project write:
4981
+ // `projectMutation` is true here and false on every render, which is what makes "a render never
4982
+ // triggers a project write" a fact an agent can test rather than a promise in prose.
4983
+ contract: contractField
4915
4984
  }).passthrough();
4916
4985
  layoutFieldDiff = z4.object({
4917
4986
  is: z4.string().describe("The value this strip actually holds. `(none)` means the field is absent."),
@@ -5137,12 +5206,12 @@ var init_outputSchemas = __esm({
5137
5206
  model: z4.enum(["metered", "anonymous_allowance", "unmetered"]),
5138
5207
  previewPanel: z4.number(),
5139
5208
  fullResolutionPanel: z4.number()
5140
- }).passthrough().describe("How work is paid for here, in public credits. Every figure comes from the monetization policy."),
5209
+ }).passthrough().describe("How work is paid for here, in public cloud credits. Every figure comes from the monetization policy."),
5141
5210
  failureCatalog: z4.object({ codes: z4.array(z4.string()), nextActions: z4.array(z4.string()) }).passthrough().describe("The CLOSED set of failure codes and next-actions this server can produce. Identical on both doors."),
5142
5211
  account: z4.object({
5143
5212
  credits: accountCreditBalanceSchema.optional()
5144
5213
  }).passthrough().describe(
5145
- "The account-wide wallet, named apart from a single call\u2019s debit: this is the BALANCE, while a render\u2019s `contract.cost.settled` is what that one call took. The legacy top-level `credits` key still carries the same balance for the compatibility window."
5214
+ "The account-wide cloud credit wallet, named apart from a single call\u2019s debit: this is the BALANCE, while a render\u2019s `contract.cost.settled` is what that one call took. The legacy top-level `credits` key still carries the same balance for the compatibility window."
5146
5215
  )
5147
5216
  }).passthrough();
5148
5217
  requestScreenshotUploadOutputSchema = z4.object({
@@ -5157,7 +5226,8 @@ var init_outputSchemas = __esm({
5157
5226
  )
5158
5227
  }).passthrough()
5159
5228
  ),
5160
- instructions: z4.string()
5229
+ instructions: z4.string(),
5230
+ disclosure: disclosureField
5161
5231
  }).passthrough();
5162
5232
  importedScreenshotOutput = z4.object({
5163
5233
  ok: z4.boolean(),
@@ -5173,7 +5243,8 @@ var init_outputSchemas = __esm({
5173
5243
  ok: z4.boolean(),
5174
5244
  count: z4.number().int().nonnegative(),
5175
5245
  screenshots: z4.array(importedScreenshotOutput),
5176
- instructions: z4.string()
5246
+ instructions: z4.string(),
5247
+ disclosure: disclosureField
5177
5248
  }).passthrough();
5178
5249
  deleteAssetsOutputSchema = z4.object({
5179
5250
  ok: z4.boolean(),
@@ -5181,6 +5252,17 @@ var init_outputSchemas = __esm({
5181
5252
  deletedRefs: z4.array(z4.string()),
5182
5253
  message: z4.string()
5183
5254
  }).passthrough();
5255
+ advertisedWithFailure = (strict) => z4.object({ ...optionalShape(strict.shape), ok: z4.boolean().optional() }).passthrough();
5256
+ refineProjectToolOutputSchema = advertisedWithFailure(refineProjectOutputSchema);
5257
+ saveProjectToolOutputSchema = advertisedWithFailure(saveProjectOutputSchema);
5258
+ readLookToolOutputSchema = advertisedWithFailure(readLookOutputSchema);
5259
+ readProjectToolOutputSchema = advertisedWithFailure(readProjectOutputSchema);
5260
+ saveLookToolOutputSchema = advertisedWithFailure(saveLookOutputSchema);
5261
+ holdLookToolOutputSchema = advertisedWithFailure(holdLookOutputSchema);
5262
+ releaseLookToolOutputSchema = advertisedWithFailure(releaseLookOutputSchema);
5263
+ requestScreenshotUploadToolOutputSchema = advertisedWithFailure(requestScreenshotUploadOutputSchema);
5264
+ importScreenshotToolOutputSchema = advertisedWithFailure(importScreenshotOutputSchema);
5265
+ deleteAssetsToolOutputSchema = advertisedWithFailure(deleteAssetsOutputSchema);
5184
5266
  }
5185
5267
  });
5186
5268
 
@@ -5278,7 +5360,7 @@ var init_schemas = __esm({
5278
5360
  "Render a specific saved look version of the project (implies the saved look). Omit = the project's HELD version if one is held (hold_look), else the latest saved look."
5279
5361
  );
5280
5362
  createProjectFlag = z5.boolean().optional().describe(
5281
- "Also save this render as an editable ShotOps project the signed-in user can open + refine (returns projectId + an openUrl). Pass an existing `project` id to update it instead of creating a new one. Full-res only \u2014 not allowed with preview:true. The record embeds no image bytes; a token-backed local save may separately upload private source refs so the project reopens with its screenshots."
5363
+ "COMPATIBILITY INPUT: also save this render as an editable ShotOps project (returns projectId + an openUrl). Set it ONLY when the user explicitly asked for both the bundle and a saved project \u2014 never infer it from a delivery request, and prefer save_project, which is the normal persistence door. Pass an existing `project` id to update that project instead of creating a new one. Full-res only \u2014 not allowed with preview:true. The record embeds no image bytes; a token-backed local save may separately upload private source refs so the project reopens with its screenshots."
5282
5364
  );
5283
5365
  projectName = z5.string().optional().describe('Name for the created project (when createProject makes a new one). Default "ShotOps render".');
5284
5366
  sourceDir = z5.string().optional().describe(
@@ -5801,7 +5883,7 @@ var init_monetizationPolicy = __esm({
5801
5883
  failedRenewalGraceMs: 7 * 24 * 60 * 60 * 1e3
5802
5884
  }
5803
5885
  };
5804
- LEDGER_UNITS_PER_PUBLIC_CREDIT = 2500;
5886
+ LEDGER_UNITS_PER_PUBLIC_CREDIT = 1250;
5805
5887
  }
5806
5888
  });
5807
5889
 
@@ -9294,6 +9376,9 @@ function assertOutcome(outcome) {
9294
9376
  async function runAgentTurn(input, ports) {
9295
9377
  const instruction = input.instruction.trim();
9296
9378
  if (!instruction) throw new Error("Agent instruction must not be empty");
9379
+ const turnId = input.turnId;
9380
+ if (!turnId) throw new Error("Agent turn id must not be empty");
9381
+ const emit = (event) => ports.emit({ ...event, turnId });
9297
9382
  const now = input.now ?? (() => (/* @__PURE__ */ new Date()).toISOString());
9298
9383
  const loaded = await ports.loadProject();
9299
9384
  const before = loaded.state;
@@ -9311,7 +9396,7 @@ async function runAgentTurn(input, ports) {
9311
9396
  let terminalOutcome = null;
9312
9397
  let invalid = false;
9313
9398
  let cancelled = false;
9314
- await ports.emit({ type: "turn_started" });
9399
+ await emit({ type: "turn_started" });
9315
9400
  try {
9316
9401
  rendered = await ports.renderComposed(working, 0);
9317
9402
  } catch {
@@ -9326,7 +9411,7 @@ async function runAgentTurn(input, ports) {
9326
9411
  break;
9327
9412
  }
9328
9413
  const phase = phaseFor(cycle);
9329
- await ports.emit({ type: "phase_changed", cycle, phase });
9414
+ await emit({ type: "phase_changed", cycle, phase });
9330
9415
  let authorization;
9331
9416
  try {
9332
9417
  authorization = await ports.authorize({
@@ -9355,7 +9440,7 @@ async function runAgentTurn(input, ports) {
9355
9440
  createdAt: now()
9356
9441
  };
9357
9442
  passes.push(pass);
9358
- await ports.emit({ type: "pass_completed", cycle, pass });
9443
+ await emit({ type: "pass_completed", cycle, pass });
9359
9444
  if (input.cancelled?.()) {
9360
9445
  cancelled = true;
9361
9446
  break;
@@ -9399,7 +9484,7 @@ async function runAgentTurn(input, ports) {
9399
9484
  };
9400
9485
  selected = checkpoint;
9401
9486
  if (checkpoint.changed.length > 0 && (!best || checkpoint.quality >= best.quality)) best = checkpoint;
9402
- await ports.emit({
9487
+ await emit({
9403
9488
  type: "draft_applied",
9404
9489
  cycle,
9405
9490
  checkpointId: checkpoint.id,
@@ -9431,7 +9516,7 @@ async function runAgentTurn(input, ports) {
9431
9516
  if (terminalOutcome || invalid) best = null;
9432
9517
  selected = best;
9433
9518
  const selectedState = selected?.state ?? before;
9434
- await ports.emit({ type: "draft_selected", checkpointId: selected?.id ?? 0, state: selectedState });
9519
+ await emit({ type: "draft_selected", checkpointId: selected?.id ?? 0, state: selectedState });
9435
9520
  let outcome = terminalOutcome ?? applicationOutcome({
9436
9521
  changed: selected?.changed ?? [],
9437
9522
  invalid,
@@ -9440,7 +9525,7 @@ async function runAgentTurn(input, ports) {
9440
9525
  let committed = false;
9441
9526
  let commitData;
9442
9527
  if (outcome.status === "applied" || outcome.status === "applied_with_remaining") {
9443
- await ports.emit({ type: "commit_started", outcome });
9528
+ await emit({ type: "commit_started", outcome });
9444
9529
  const commit = await ports.commit({
9445
9530
  before,
9446
9531
  after: selectedState,
@@ -9451,13 +9536,13 @@ async function runAgentTurn(input, ports) {
9451
9536
  if (commit.ok) {
9452
9537
  committed = true;
9453
9538
  commitData = commit.data;
9454
- await ports.emit({ type: "commit_succeeded", data: commit.data, outcome });
9539
+ await emit({ type: "commit_succeeded", data: commit.data, outcome });
9455
9540
  } else {
9456
9541
  outcome = commit.reason === "stale_version" ? applicationOutcome({ changed: [], conflicted: true }) : failureOutcome("service_unavailable", "The Agent could not save this change. Nothing changed.");
9457
- await ports.emit({ type: "draft_rolled_back", outcome });
9542
+ await emit({ type: "draft_rolled_back", outcome });
9458
9543
  }
9459
9544
  } else {
9460
- await ports.emit({ type: "draft_rolled_back", outcome });
9545
+ await emit({ type: "draft_rolled_back", outcome });
9461
9546
  }
9462
9547
  const changed = committed ? selected?.changed ?? [] : [];
9463
9548
  const selectedStale = staleCaptionLocaleCount(selectedState.staleCaptionTranslations);
@@ -9480,6 +9565,7 @@ async function runAgentTurn(input, ports) {
9480
9565
  usage: aggregateUsage(passes)
9481
9566
  };
9482
9567
  const result = {
9568
+ turnId,
9483
9569
  outcome,
9484
9570
  legacy,
9485
9571
  before,
@@ -9489,7 +9575,7 @@ async function runAgentTurn(input, ports) {
9489
9575
  ...commitData === void 0 ? {} : { commitData }
9490
9576
  };
9491
9577
  assertOutcome(outcome);
9492
- await ports.emit({ type: "turn_completed", result });
9578
+ await emit({ type: "turn_completed", result });
9493
9579
  void input.requestedCycle;
9494
9580
  return result;
9495
9581
  }
@@ -9535,6 +9621,9 @@ var init_agentTurnConformance = __esm({
9535
9621
  };
9536
9622
  AGENT_TURN_CONFORMANCE_FIXTURE = {
9537
9623
  input: {
9624
+ // A uuid, because every surface's receipt keys on this value and the receipt table's primary
9625
+ // key is one. The fixture is what a surface adapter replays, so it carries a real-shaped id.
9626
+ turnId: "4f4b2f4a-4a7b-4f31-8e21-2c1c4a9b6d10",
9538
9627
  instruction: "Give the phone more depth",
9539
9628
  focus: { kind: "device", frameId: "p1", deviceId: "s1", label: "Frame 1 \xB7 home.png" }
9540
9629
  },
@@ -10082,6 +10171,12 @@ function backgroundViewport(value, label) {
10082
10171
  }
10083
10172
  return { panelIndex: Number(row.panelIndex), panelCount: Number(row.panelCount) };
10084
10173
  }
10174
+ function textList(value, label, allowEmpty = false) {
10175
+ if (!Array.isArray(value) || !allowEmpty && value.length === 0) {
10176
+ throw new ProductionWorkerRequestError(`${label} must be ${allowEmpty ? "an" : "a non-empty"} array`);
10177
+ }
10178
+ return value.map((entry, index) => text(entry, `${label}[${index}]`));
10179
+ }
10085
10180
  function uniqueTexts(value, label, allowEmpty = false) {
10086
10181
  if (!Array.isArray(value) || !allowEmpty && value.length === 0) {
10087
10182
  throw new ProductionWorkerRequestError(`${label} must be ${allowEmpty ? "an" : "a non-empty"} array`);
@@ -10102,7 +10197,10 @@ function panelRecipe(value, index, allowEmptyScreenshots) {
10102
10197
  const viewport = backgroundViewport(row.backgroundViewport, `panels[${index}].backgroundViewport`);
10103
10198
  return {
10104
10199
  key: text(row.key, `panels[${index}].key`),
10105
- screenshotAssetIds: uniqueTexts(
10200
+ // A panel is a COMPOSITION, not a set of images: positions may repeat the same picture, and
10201
+ // `loadAssets` already dedupes ids into a Map before resolving bytes. Uniqueness here rejected
10202
+ // legal input — `uniqueTexts` belongs on `bundle.locales`, where a repeat means something.
10203
+ screenshotAssetIds: textList(
10106
10204
  row.screenshotAssetIds,
10107
10205
  `panels[${index}].screenshotAssetIds`,
10108
10206
  allowEmptyScreenshots
@@ -10312,7 +10410,7 @@ async function startDurableProduction(deps, input) {
10312
10410
  return errorResult("That idempotency key already belongs to a different production request.", "idempotency_conflict");
10313
10411
  }
10314
10412
  if (error instanceof ProductionReservationInsufficientCreditsError) {
10315
- return errorResult("There are not enough credits to reserve this production render.", "quota_exhausted", {
10413
+ return errorResult("There are not enough cloud credits to reserve this production render.", "quota_exhausted", {
10316
10414
  required: publicCreditsForLedgerUnits(error.required),
10317
10415
  available: publicCreditsForLedgerUnits(error.availability.available)
10318
10416
  });
@@ -10375,7 +10473,7 @@ var init_package = __esm({
10375
10473
  "package.json"() {
10376
10474
  package_default = {
10377
10475
  name: "shotops-mcp",
10378
- version: "0.9.0",
10476
+ version: "0.9.2",
10379
10477
  private: false,
10380
10478
  type: "module",
10381
10479
  description: "The bundle-emitting MCP server over the @engine/@sync spine. Exposes ShotOps tools to ChatGPT, Claude Code, Cursor, and CI over Streamable HTTP with OAuth or personal API tokens. Hosted and MCP tool paths never accept, store, or forward a store-signing credential; the explicit local release CLI validates the user's existing key directly with Apple and records only its path. Renders via headless Playwright Chromium, the engine's native non-browser habitat (same path as mockup-mcp). Also ships as a free LOCAL stdio server (`npx shotops-mcp`) \u2014 same render, on your own machine, no account needed.",
@@ -10867,7 +10965,7 @@ async function renderStrip(deps, args, scope, signal) {
10867
10965
  locale: locale2,
10868
10966
  ...screenshotLocales.length > 0 ? { screenshotLocales } : {},
10869
10967
  ...note ? { note } : {},
10870
- ...args.project ? {} : { nextStep: SAVE_PROJECT_NEXT_STEP },
10968
+ ...args.project ? {} : { nextStep: OFFER_SAVE_PROJECT_NEXT_STEP },
10871
10969
  resolvedInput: resolvedInputContract(snapshot),
10872
10970
  readiness: readinessContract(preReady.report)
10873
10971
  }
@@ -10943,15 +11041,16 @@ async function renderStrip(deps, args, scope, signal) {
10943
11041
  // #563 — the composition's own verdict, ADDITIVE: absent on a clean render, so a response with
10944
11042
  // nothing to say keeps exactly the key set it had before.
10945
11043
  ...result.layout ? { layout: result.layout } : {},
10946
- // #213 — tell the model, in the output, to turn this render into a real project. The prose
10947
- // contract already requires it; this is the copy a client cannot drop (toolContract.ts).
11044
+ // The OFFER, in the output, because a client may never have received the prose contract
11045
+ // (toolContract.ts owns the wording). It states that no project exists and names the door; it
11046
+ // does not tell an agent to walk through it, and `consent: "required"` says so on the wire.
10948
11047
  //
10949
11048
  // Only when the render was NOT tied to a project. On render_strip, `project` names a LOOK
10950
11049
  // SOURCE, not a save target — so a caller who already has a project in play must not be nudged
10951
11050
  // into minting a second one, and telling them to UPDATE it would be wrong too (they asked to
10952
11051
  // render with its styling, not to file work under it). That case stays with the prose, which
10953
11052
  // handles it correctly: "Never infer an update target from the most-recent project."
10954
- ...args.project ? {} : { nextStep: SAVE_PROJECT_NEXT_STEP },
11053
+ ...args.project ? {} : { nextStep: OFFER_SAVE_PROJECT_NEXT_STEP },
10955
11054
  // #662 — the versioned result envelope. Additive: every field above is unchanged, and an old
10956
11055
  // client that has never heard of `contract` sees exactly the payload it saw yesterday.
10957
11056
  contract: successContract(deps, {
@@ -13047,12 +13146,14 @@ async function handleImportScreenshot(deps, args) {
13047
13146
  return errorResult(results.map((r) => r.error).filter(Boolean).join("\n\n"));
13048
13147
  }
13049
13148
  const locale2 = args.locale?.trim() || void 0;
13149
+ const stored = imported.some((result) => typeof result.ref === "string" && result.ref.length > 0);
13050
13150
  return textResult({
13051
13151
  ok: imported.length === results.length,
13052
13152
  count: imported.length,
13053
13153
  screenshots: results,
13054
13154
  ...locale2 ? { locale: locale2 } : {},
13055
- instructions: instructionsFor(results)
13155
+ ...stored ? { disclosure: { ...ACCOUNT_UPLOAD_DISCLOSURE, statement: disclosureStatement(ACCOUNT_UPLOAD_DISCLOSURE) } } : {},
13156
+ instructions: instructionsFor(results) + (stored ? ` ${disclosureStatement(ACCOUNT_UPLOAD_DISCLOSURE)}` : "")
13056
13157
  });
13057
13158
  }
13058
13159
  var init_toolsImport = __esm({
@@ -13084,7 +13185,11 @@ async function handleRequestScreenshotUpload(deps, args) {
13084
13185
  slots: slots.map(({ assetId: _assetId, ...slot2 }) => ({ ...slot2, variant })),
13085
13186
  ...locale2 ? { locale: locale2 } : {},
13086
13187
  ...family ? { family } : {},
13087
- instructions: 'PUT each screenshot\'s raw PNG bytes to its uploadUrl, e.g.: curl -T screenshot.png "$UPLOAD_URL" \u2014 then pass { "ref": "THE_MATCHING_REF" } as that screenshot\'s entry in render_strip/emit_bundle\'s `screenshots` array. If you passed `names`, that ref already carries its filename \u2014 no need to also set `name` on the screenshot entry. Unretained uploads expire after 7 days; saving them in a Project retains them while referenced.' + (locale2 ? ` For render_strip/emit_bundle, place each ref under the matching per-locale key, e.g. { "locales": { "${locale2}": { "ref": "THE_REF" } } }.` : "") + (locale2 || family ? " For render_project, pass each slot's `variant` through with its ref so that exact device-family + locale cell overrides the project instead of its base screenshot." : "")
13188
+ // Minting a slot is the moment to say where the bytes are about to go, not after they are
13189
+ // there. The window is read from the retention policy — typing "7 days" here is how two
13190
+ // surfaces end up promising different weeks.
13191
+ disclosure: { ...ACCOUNT_UPLOAD_DISCLOSURE, statement: disclosureStatement(ACCOUNT_UPLOAD_DISCLOSURE) },
13192
+ instructions: `PUT each screenshot's raw PNG bytes to its uploadUrl, e.g.: curl -T screenshot.png "$UPLOAD_URL" \u2014 then pass { "ref": "THE_MATCHING_REF" } as that screenshot's entry in render_strip/emit_bundle's \`screenshots\` array. If you passed \`names\`, that ref already carries its filename \u2014 no need to also set \`name\` on the screenshot entry. ${disclosureStatement(ACCOUNT_UPLOAD_DISCLOSURE)}` + (locale2 ? ` For render_strip/emit_bundle, place each ref under the matching per-locale key, e.g. { "locales": { "${locale2}": { "ref": "THE_REF" } } }.` : "") + (locale2 || family ? " For render_project, pass each slot's `variant` through with its ref so that exact device-family + locale cell overrides the project instead of its base screenshot." : "")
13088
13193
  });
13089
13194
  }
13090
13195
  async function handleDeleteAssets(deps, args) {
@@ -13118,7 +13223,7 @@ async function handleDeleteAssets(deps, args) {
13118
13223
  deletedCount: deletedAssetIds.length,
13119
13224
  deletedAssetIds,
13120
13225
  notFoundAssetIds: results.filter((result) => result.outcome === "not_found").map((result) => result.requestedAssetId),
13121
- message: "Deleted every unreferenced owned asset. Missing or already-deleted assets are idempotent; live dependencies are never removed."
13226
+ message: "Deleted every unreferenced owned asset. Missing or already-deleted assets are idempotent; live dependencies are never removed. An asset a Project, operation, share or preview still points at is refused rather than removed, and is never worth proposing for cleanup."
13122
13227
  });
13123
13228
  } catch (err) {
13124
13229
  return errorResult(err.message);
@@ -13128,6 +13233,7 @@ var init_toolsUploads = __esm({
13128
13233
  "src/toolsUploads.ts"() {
13129
13234
  "use strict";
13130
13235
  init_tools();
13236
+ init_toolContract();
13131
13237
  init_screenVariants();
13132
13238
  }
13133
13239
  });
@@ -13343,7 +13449,16 @@ async function handleSaveProject(deps, args) {
13343
13449
  locale: locale2,
13344
13450
  ...outputDevices.length > 0 ? { outputs: outputDevices.map((d) => d.id) } : {},
13345
13451
  ...note ? { note } : {},
13346
- message: `Project staged as a private pending claim \u2014 open ${p.openUrl} and sign in within 7 days to become its owner. Always give this openUrl to the user; no projectId exists until the claim is completed.`
13452
+ // The one local path that moves bytes off this machine. Stated with the result, from the
13453
+ // retention policy's own number, rather than left for the user to discover when the claim
13454
+ // window closes over their staged PNGs.
13455
+ disclosure: { ...PENDING_CLAIM_DISCLOSURE, statement: disclosureStatement(PENDING_CLAIM_DISCLOSURE) },
13456
+ contract: successContract(deps, {
13457
+ tool: "save_project",
13458
+ kind: "mutate",
13459
+ effects: { projectMutation: true, upload: true, retainedStorage: true }
13460
+ }),
13461
+ message: `Project staged as a private pending claim \u2014 open ${p.openUrl} and sign in within ${PENDING_CLAIM_DISCLOSURE.retentionDays} days to become its owner. Always give this openUrl to the user; no projectId exists until the claim is completed. ` + disclosureStatement(PENDING_CLAIM_DISCLOSURE)
13347
13462
  });
13348
13463
  }
13349
13464
  return textResult({
@@ -13357,6 +13472,16 @@ async function handleSaveProject(deps, args) {
13357
13472
  // byte-identical to before.
13358
13473
  ...outputDevices.length > 0 ? { outputs: outputDevices.map((d) => d.id) } : {},
13359
13474
  ...note ? { note } : {},
13475
+ // #662 — this is the ONE tool in the contract whose job is a project write, and the envelope
13476
+ // says so: `projectMutation: true` here, false on every render. That pair is what makes
13477
+ // "a render never creates a project on its own" checkable rather than merely promised.
13478
+ // Nothing is uploaded on this path: the record is structure + look, and the refs it points at
13479
+ // were already in storage before this call.
13480
+ contract: successContract(deps, {
13481
+ tool: "save_project",
13482
+ kind: "mutate",
13483
+ effects: { projectMutation: true, retainedStorage: true }
13484
+ }),
13360
13485
  message: `Saved as an editable ShotOps project \u2014 open it at ${p.openUrl} (sign in as the same account). Thread this projectId back on later renders to update it in place.`
13361
13486
  });
13362
13487
  }
@@ -13497,17 +13622,15 @@ var init_toolsProject = __esm({
13497
13622
  });
13498
13623
 
13499
13624
  // src/refineProjectReceipt.ts
13500
- import { randomUUID as randomUUID3 } from "node:crypto";
13501
- function mintTurnId() {
13502
- return `turn_${randomUUID3()}`;
13503
- }
13504
13625
  function failure(error, message) {
13505
13626
  return { ok: false, error, message };
13506
13627
  }
13507
- function refineProjectReceipt(turnId, turn) {
13628
+ function refineProjectReceipt(turn) {
13508
13629
  return {
13509
13630
  ok: true,
13510
- turnId,
13631
+ // The id the ADAPTER minted for this turn (#580), never one a caller supplied and never one a
13632
+ // door invented alongside it: the receipt row and this line carry the same identity.
13633
+ turnId: turn.turnKey,
13511
13634
  projectId: turn.projectId,
13512
13635
  outcome: turn.outcome,
13513
13636
  changed: [...turn.outcome.changed],
@@ -13519,11 +13642,11 @@ function refineProjectReceipt(turnId, turn) {
13519
13642
  allowance: turn.allowance
13520
13643
  };
13521
13644
  }
13522
- function refineProjectResult(turnId, turn) {
13645
+ function refineProjectResult(turn) {
13523
13646
  if ("error" in turn) {
13524
13647
  return failure(turn.error, turn.message);
13525
13648
  }
13526
- return refineProjectReceipt(turnId, turn);
13649
+ return refineProjectReceipt(turn);
13527
13650
  }
13528
13651
  function refineProjectUnavailable(message) {
13529
13652
  return failure("service_unavailable", message);
@@ -13693,7 +13816,7 @@ function previewUsage(deps, snapshot) {
13693
13816
  }
13694
13817
  function statusMessage(deps, snapshot, step) {
13695
13818
  if (step === "none") {
13696
- return "This connection can produce store-ready output: full-resolution render_strip and render_project, and every emit_bundle form." + (isLocal(deps) ? " Local rendering spends no credits." : "");
13819
+ return "This connection can produce store-ready output: full-resolution render_strip and render_project, and every emit_bundle form." + (isLocal(deps) ? " Local rendering spends no cloud credits." : "");
13697
13820
  }
13698
13821
  if (step === "sign_in" && !snapshot.authenticated && deps.anonymous) {
13699
13822
  return `This connection is not signed in. It can import screenshots and render up to ${ANONYMOUS.attempts} preview strips of up to ${ANONYMOUS.maxPanels} panels each per ${Math.round(ANONYMOUS.windowSeconds / 86400)} days. ` + productionDenialMessage(snapshot.productionExport, deps);
@@ -13904,7 +14027,7 @@ async function handleReadLook(deps, args) {
13904
14027
  return textResult({
13905
14028
  saved: false,
13906
14029
  project: { id: project2.id, name: project2.name },
13907
- message: `No saved look on project "${project2.name}". Style a strip in ShotOps and use the Edited chip \u2192 Save this version, or compose one here with describe_look + save_look.`
14030
+ message: `No saved look on project "${project2.name}". Style a strip in ShotOps and use the Save version button on the stage, or compose one here with describe_look + save_look.`
13908
14031
  });
13909
14032
  }
13910
14033
  const layout = deriveLayoutReport(saved.look);
@@ -14610,7 +14733,7 @@ function registerTools(server, deps) {
14610
14733
  "render_strip",
14611
14734
  {
14612
14735
  title: "Render App Store screenshots",
14613
- description: 'Make the store screenshots: raw app screen captures in, finished marketing panels out. Each screenshot is composited into a styled 3D device mockup, laid out as an App Store screenshot strip with the headlines you pass, and returned as per-panel PNGs at the App Store panel size `panelPresetId` names. This is the tool for a strip built from screenshots you have in hand; to reproduce one a designer already saved, use render_project. In ChatGPT, call import_screenshot first and pass the returned refs in the preferred flat form: { "screenshots": [{ "ref": "\u2026" }] } \u2014 each flat entry is one panel, and nested slots only when ONE panel should hold several phones. Art-direct it with `style`/`look` or a project\'s saved look, iterate with a low-res `preview` at a quarter the render cost, and take results `inline` or as `urls` for large payloads. Screenshots can vary per store locale via { "locales": \u2026 } entries \u2014 `locale` picks which variant renders (missing variants fall back to en-US). For real, full-resolution screenshots call request_screenshot_upload first \u2014 inline base64 is for small payloads only. CALL describe_look FIRST when nobody has said how the strip should look \u2014 it returns the five palette presets, the three layout templates and every styling field with its default, and guessing a colour scheme out of 30 raw fields is how strips end up looking improvised. PICK `style.layout` FROM THE LONGEST HEADLINE IN THE SET, once, before anything else: "standard" reserves 2 lines (~36 characters) with the whole device under them, "bleed" reserves 4 (~72) with the device running off the bottom edge, "top-bleed" inverts that. The text region is RESERVED, NOT FITTED \u2014 held at full size whether or not the headline fills it, because a fixed reservation is the only thing that makes every device in a swiped SET land on the same line. Do not close a gap by shrinking vOffset on the short panels, and never assemble a composition out of raw phoneHeight/vOffset numbers: pick a layout, then override one field if you must (the template expands first, your field wins). Past the reservation the caption band grows down INTO the phone \u2014 the failure to avoid. THE RESPONSE ANSWERS BACK: an optional `layout` block names every caption sitting on a device, every caption collision and every device pair overlapping without reading as composed, by frame number and depth. When the repair is deterministic it also carries `layout.correctedLook` \u2014 pass that straight back as `look` to re-render clean. No `layout` key means the composition measured clean. Read it before you show the panels. This tool ONLY renders images: it never creates or updates a project (use save_project for that) and no store credential is involved.',
14736
+ description: 'Make the store screenshots: raw app screen captures in, finished marketing panels out. Each screenshot is composited into a styled 3D device mockup, laid out as an App Store screenshot strip with the headlines you pass, and returned as per-panel PNGs at the App Store panel size `panelPresetId` names. This is the tool for a strip built from screenshots you have in hand; to reproduce one a designer already saved, use render_project. In ChatGPT, call import_screenshot first and pass the returned refs in the preferred flat form: { "screenshots": [{ "ref": "\u2026" }] } \u2014 each flat entry is one panel, and nested slots only when ONE panel should hold several phones. Art-direct it with `style`/`look` or a project\'s saved look, iterate with a low-res `preview` at a quarter the render cost, and take results `inline` or as `urls` for large payloads. Screenshots can vary per store locale via { "locales": \u2026 } entries \u2014 `locale` picks which variant renders (missing variants fall back to en-US). For real, full-resolution screenshots call request_screenshot_upload first \u2014 inline base64 is for small payloads only. CALL describe_look FIRST when nobody has said how the strip should look \u2014 it returns the five palette presets, the three layout templates and every styling field with its default, and guessing a colour scheme out of 30 raw fields is how strips end up looking improvised. PICK `style.layout` FROM THE LONGEST HEADLINE IN THE SET, once, before anything else: "standard" reserves 2 lines (~36 characters) with the whole device under them, "bleed" reserves 4 (~72) with the device running off the bottom edge, "top-bleed" inverts that. The text region is RESERVED, NOT FITTED \u2014 held at full size whether or not the headline fills it, because a fixed reservation is the only thing that makes every device in a swiped SET land on the same line. Do not close a gap by shrinking vOffset on the short panels, and never assemble a composition out of raw phoneHeight/vOffset numbers: pick a layout, then override one field if you must (the template expands first, your field wins). Past the reservation the caption band grows down INTO the phone \u2014 the failure to avoid. THE RESPONSE ANSWERS BACK: an optional `layout` block names every caption sitting on a device, every caption collision and every device pair overlapping without reading as composed, by frame number and depth. When the repair is deterministic it also carries `layout.correctedLook` \u2014 pass that straight back as `look` to re-render clean. No `layout` key means the composition measured clean. Read it before you show the panels. This tool ONLY renders images: it never creates or updates a project, and the result says so \u2014 `contract.effects.projectMutation` is false on every render. A render with no `project` also carries a `nextStep` with action `offer_save_project`, which states that no project exists and names save_project as the door. That is an OFFER to put to the user, never consent already given: save only after they show they want this work preserved, shared, reused or kept editable, and if you cannot ask them, return the panels with the offer standing. No store credential is involved.',
14614
14737
  inputSchema: renderStripAdvertisedShape,
14615
14738
  outputSchema: renderStripToolOutputSchema,
14616
14739
  // ADDITIVE, not destructive: this can mint private generated assets but cannot change a
@@ -14631,7 +14754,7 @@ function registerTools(server, deps) {
14631
14754
  "emit_bundle",
14632
14755
  {
14633
14756
  title: "Export fastlane bundle",
14634
- description: 'Deliver the finished store screenshots as one file the user can actually upload: a `fastlane deliver`-ready zip holding the screenshots, a pre-filled Deliverfile that is screenshots-only and never submits, and a README. Three ways in, same zip out \u2014 render the strip here from `screenshots`; re-zip pre-rendered `panels` refs from a prior render_strip `output: "urls"` call (no re-render, near-instant); or name a saved `project` alone and it packages the screenshots that project already holds, with nothing attached to this conversation. `locales: [...]` emits ONE localized bundle with a fastlane/screenshots/<locale>/ folder per language, so every language uploads in a single fastlane run \u2014 pair it with per-locale { "locales": \u2026 } screenshot/panel entries, or with a `project`, which supplies each locale\'s own caption words. Returns the zip `inline` or as a `url` for large payloads, and can mint a 14-day share link to a landing page. YOU upload it with your own fastlane: this server never touches an Apple or store credential, never creates an App Store version, and never submits for review.',
14757
+ description: 'Deliver the finished store screenshots as one file the user can actually upload: a `fastlane deliver`-ready zip holding the screenshots, a pre-filled Deliverfile that is screenshots-only and never submits, and a README. Three ways in, same zip out \u2014 render the strip here from `screenshots`; re-zip pre-rendered `panels` refs from a prior render_strip `output: "urls"` call (no re-render, near-instant); or name a saved `project` alone and it packages the screenshots that project already holds, with nothing attached to this conversation. `locales: [...]` emits ONE localized bundle with a fastlane/screenshots/<locale>/ folder per language, so every language uploads in a single fastlane run \u2014 pair it with per-locale { "locales": \u2026 } screenshot/panel entries, or with a `project`, which supplies each locale\'s own caption words. Returns the zip `inline` or as a `url` for large payloads, and can mint a 14-day share link to a landing page. Bundling is not saving: `createProject` is a compatibility input kept for older callers, so set it only when the user explicitly asked for BOTH a bundle and a saved project \u2014 never infer it from a delivery request, and use save_project for an ordinary save. YOU upload the zip with your own fastlane: this server never touches an Apple or store credential, never creates an App Store version, and never submits for review.',
14635
14758
  inputSchema: emitBundleAdvertisedShape,
14636
14759
  outputSchema: emitBundleToolOutputSchema,
14637
14760
  annotations: OPEN_WORLD_DESTRUCTIVE_WRITE,
@@ -14652,9 +14775,9 @@ function registerTools(server, deps) {
14652
14775
  "save_project",
14653
14776
  {
14654
14777
  title: "Save as editable project",
14655
- description: "Let the user keep the strip: save it as an editable ShotOps project they can open in the web app, refine by hand, and have re-rendered later by render_project. Returns an openUrl to hand back and, when already authenticated, a projectId. Unsigned local stdio instead returns an explicit seven-day pending claim; the user owns it after opening the URL and signing in. No panel PNGs are returned. Do this when the design should outlive the conversation, not just when they want the images. Fast, and it does NOT render: it builds a byte-free structure + look record, so no panel bytes are returned and it never hits the render timeout. Unsigned local save deliberately uploads the raw local PNGs to temporary private claim storage; ordinary local render/export never uploads. `outputs` names the device sizes the project targets; an existing `project` id updates that project in place, omitting it creates a new one. For new MCP-created work this is the default final persistence step: omit `project` to create a new one and give the returned openUrl to the user. Skip it only when the user explicitly says not to create/save a project; pass `project` only when they explicitly say the work belongs in that existing project.",
14778
+ description: "Let the user keep the strip: save it as an editable ShotOps project they can open in the web app, refine by hand, and have re-rendered later by render_project. Returns an openUrl to hand back and, when already authenticated, a projectId. Unsigned local stdio instead returns an explicit pending claim; the user owns it after opening the URL and signing in. No panel PNGs are returned. Fast, and it does NOT render: it builds a byte-free structure + look record, so no panel bytes are returned and it never hits the render timeout. `outputs` names the device sizes the project targets; an existing `project` id updates that project in place, omitting it creates a new one. CALL IT WHEN THE USER ASKS FOR PERSISTENCE, not as the closing step of every job: this is the door for someone who said they want to keep, share, reuse or keep editing the work. A request for images, full-resolution panels or a bundle is not that, and neither is silence \u2014 a render already told them nothing was saved and offered this call. Pass `project` only when they named an existing project as the target; never infer one from the most recently edited. An UNSIGNED local save is the one local path that moves bytes: it stages the raw PNGs in private claim storage so the claimed project reopens whole, and its result carries a `disclosure` block naming what moved, why, how long it is kept and what retains it. Ordinary local render/export never uploads.",
14656
14779
  inputSchema: saveProjectAdvertisedShape,
14657
- outputSchema: saveProjectOutputSchema,
14780
+ outputSchema: saveProjectToolOutputSchema,
14658
14781
  annotations: DESTRUCTIVE_WRITE,
14659
14782
  _meta: toolMeta(deps, "save_project", "Saving ShotOps project\u2026", "ShotOps project saved")
14660
14783
  },
@@ -14666,7 +14789,7 @@ function registerTools(server, deps) {
14666
14789
  title: "Read saved look",
14667
14790
  description: "Match a design the user already approved instead of inventing a new one: returns the saved ShotOps look on a project \u2014 the device, background and caption styling a human tuned in the web app \u2014 so the next render comes out looking like theirs. Styling only: no screenshots, no caption words, no credentials. Comes with the latest version, the held version (if any) and the full `versions` history. Feed the returned look back into render_strip/emit_bundle as `look`, or just pass `useSavedLook: true`; render an older entry with `version`, or make one the default with hold_look.",
14668
14791
  inputSchema: readLookShape,
14669
- outputSchema: readLookOutputSchema,
14792
+ outputSchema: readLookToolOutputSchema,
14670
14793
  annotations: READ_ONLY,
14671
14794
  _meta: toolMeta(deps, "read_look", "Reading saved look\u2026", "Saved look read")
14672
14795
  },
@@ -14678,7 +14801,7 @@ function registerTools(server, deps) {
14678
14801
  title: "Read full project",
14679
14802
  description: 'Find out what the designer changed in a strip: returns a project\'s FULL current state \u2014 the frame order they settled on (panels), the caption words they typed for each locale (captionText), the locale list, and the styling \u2014 as an opaque ProjectFile, not just the look. To answer a recurring "any updates?", remember the returned `updatedAt` and re-read later; a newer value means the strip was edited since. It ALSO answers whether the project already holds its own screenshots: `screenshots.available` true means render_project can render it with NO `screenshots` argument and nothing attached \u2014 check that before concluding you need the files. Read-only, never returns screenshot bytes, no store credential.',
14680
14803
  inputSchema: readProjectShape,
14681
- outputSchema: readProjectOutputSchema,
14804
+ outputSchema: readProjectToolOutputSchema,
14682
14805
  annotations: READ_ONLY,
14683
14806
  _meta: toolMeta(deps, "read_project", "Reading ShotOps project\u2026", "ShotOps project read")
14684
14807
  },
@@ -14713,7 +14836,7 @@ function registerTools(server, deps) {
14713
14836
  title: "Refine a saved project",
14714
14837
  description: "Edit an existing ShotOps project conversationally through one bounded Agent turn. Pass the project id or exact name, the requested change, and optionally the selected frame, device, or caption as focus. A supported request commits exactly once and returns the actual committed diff. Ambiguity, missing input, unsupported intent, a concurrent edit, or a failed pass returns its own typed outcome with no partial project saved. This changes the saved project but returns no panel pixels; call render_project afterwards to see or export the result.",
14715
14838
  inputSchema: refineProjectShape,
14716
- outputSchema: refineProjectOutputSchema,
14839
+ outputSchema: refineProjectToolOutputSchema,
14717
14840
  annotations: DESTRUCTIVE_WRITE,
14718
14841
  _meta: toolMeta(deps, "refine_project", "Refining ShotOps project\u2026", "ShotOps project refined")
14719
14842
  },
@@ -14725,7 +14848,7 @@ function registerTools(server, deps) {
14725
14848
  title: "Save look",
14726
14849
  description: "Make a design the user liked reusable: persists a composed ShotOps look (styling only) on a project as a new version, so their next strip \u2014 rendered here or opened in the web app \u2014 starts from the design they just approved instead of a default. Offer it once they are happy with how a strip looks. Use describe_look for the field catalog; caption words are never stored, so pass those per render. No store credential is involved.",
14727
14850
  inputSchema: saveLookAdvertisedShape,
14728
- outputSchema: saveLookOutputSchema,
14851
+ outputSchema: saveLookToolOutputSchema,
14729
14852
  annotations: ADDITIVE_WRITE,
14730
14853
  _meta: toolMeta(deps, "save_look", "Saving ShotOps look\u2026", "ShotOps look saved")
14731
14854
  },
@@ -14737,7 +14860,7 @@ function registerTools(server, deps) {
14737
14860
  title: "Hold a look version",
14738
14861
  description: "Freeze which design gets rendered: pin one saved look version as this project's DEFAULT, so a designer can keep experimenting and saving newer versions without changing what agents deliver. The version must already exist (see read_look `versions`). render_strip/emit_bundle with `useSavedLook: true` then render the held one. release_look goes back to Follow latest. No credential.",
14739
14862
  inputSchema: holdLookShape,
14740
- outputSchema: holdLookOutputSchema,
14863
+ outputSchema: holdLookToolOutputSchema,
14741
14864
  annotations: IDEMPOTENT_WRITE,
14742
14865
  _meta: toolMeta(deps, "hold_look", "Holding look version\u2026", "Look version held")
14743
14866
  },
@@ -14749,7 +14872,7 @@ function registerTools(server, deps) {
14749
14872
  title: "Release the held look",
14750
14873
  description: "Go back to rendering the newest design: clears a project\u2019s held version (undo hold_look), so agents Follow latest and pick up whatever the designer saved most recently. No-op if nothing was held. No store credential.",
14751
14874
  inputSchema: releaseLookShape,
14752
- outputSchema: releaseLookOutputSchema,
14875
+ outputSchema: releaseLookToolOutputSchema,
14753
14876
  annotations: IDEMPOTENT_WRITE,
14754
14877
  _meta: toolMeta(deps, "release_look", "Releasing look version\u2026", "Look version released")
14755
14878
  },
@@ -14771,9 +14894,9 @@ function registerTools(server, deps) {
14771
14894
  "request_screenshot_upload",
14772
14895
  {
14773
14896
  title: "Upload full-size screenshots",
14774
- description: "Get screenshots that live only on the USER's own machine into a render, at full resolution and without their bytes ever transiting this conversation. It mints signed upload URLs you (or the user's shell) PUT each raw PNG to, and hands back a `ref` per slot to pass into render_strip/emit_bundle. Use it in non-ChatGPT MCP clients and automation, and instead of inline base64 for any real full-resolution screenshot. In ChatGPT, attachments go through import_screenshot instead \u2014 and a user with a ShotOps account can skip file transfer entirely by dropping the screenshots into a project and calling render_project. Uploading one batch per store locale? Tag each batch with `locale` (echoed back for your bookkeeping).",
14897
+ description: "Get screenshots that live only on the USER's own machine into a render, at full resolution and without their bytes ever transiting this conversation. It mints signed upload URLs you (or the user's shell) PUT each raw PNG to, and hands back a `ref` per slot to pass into render_strip/emit_bundle. Use it in non-ChatGPT MCP clients and automation, and instead of inline base64 for any real full-resolution screenshot. In ChatGPT, attachments go through import_screenshot instead \u2014 and a user with a ShotOps account can skip file transfer entirely by dropping the screenshots into a project and calling render_project. Uploading one batch per store locale? Tag each batch with `locale` (echoed back for your bookkeeping). The result carries a `disclosure` block \u2014 what moves into ShotOps storage, why, how long it is kept and the explicit action that retains it. Say that before the bytes move, and relay its numbers rather than your own.",
14775
14898
  inputSchema: requestScreenshotUploadShape,
14776
- outputSchema: requestScreenshotUploadOutputSchema,
14899
+ outputSchema: requestScreenshotUploadToolOutputSchema,
14777
14900
  annotations: ADDITIVE_WRITE,
14778
14901
  _meta: toolMeta(deps, "request_screenshot_upload", "Preparing screenshot uploads\u2026", "Screenshot uploads ready")
14779
14902
  },
@@ -14787,9 +14910,9 @@ function registerTools(server, deps) {
14787
14910
  "import_screenshot",
14788
14911
  {
14789
14912
  title: "Import screenshots",
14790
- description: 'How screenshots that exist only in THIS conversation get into ShotOps so they can be rendered: one call takes several \u2014 an attached PNG or JPEG, or a PNG/JPEG URL \u2014 and hands each back as a private, account-scoped `ref` the render tools accept, with the image bytes moving server-to-server so they never enter model context. Every entry of `screenshots` names EXACTLY ONE source: { "url": "https://\u2026" } (this server fetches PNG or JPEG and converts JPEG to PNG \u2014 works on any client), { "file": \u2026 } (a ChatGPT attachment descriptor; in ChatGPT it arrives as the top-level `file` parameter instead), or (local stdio server only) { "path": "/abs/shot.png" }. Results come back one per entry in input order, and a failed entry reports its own error without cancelling the rest \u2014 re-import just that one. Then call render_strip with the preferred flat input { "screenshots": [{ "ref": "THE_REF" }] }; the same refs work in emit_bundle, and with their filenames in render_project. If the screenshots are only on the user\'s machine and this is the hosted server, there is no URL to give \u2014 use request_screenshot_upload instead.',
14913
+ description: 'How screenshots that exist only in THIS conversation get into ShotOps so they can be rendered: one call takes several \u2014 an attached PNG or JPEG, or a PNG/JPEG URL \u2014 and hands each back as a private, account-scoped `ref` the render tools accept, with the image bytes moving server-to-server so they never enter model context. Every entry of `screenshots` names EXACTLY ONE source: { "url": "https://\u2026" } (this server fetches PNG or JPEG and converts JPEG to PNG \u2014 works on any client), { "file": \u2026 } (a ChatGPT attachment descriptor; in ChatGPT it arrives as the top-level `file` parameter instead), or (local stdio server only) { "path": "/abs/shot.png" }. Results come back one per entry in input order, and a failed entry reports its own error without cancelling the rest \u2014 re-import just that one. Then call render_strip with the preferred flat input { "screenshots": [{ "ref": "THE_REF" }] }; the same refs work in emit_bundle, and with their filenames in render_project. If the screenshots are only on the user\'s machine and this is the hosted server, there is no URL to give \u2014 use request_screenshot_upload instead. An import that produced refs puts the bytes in ShotOps storage and says so in a `disclosure` block: what moved, why, how long it is kept and the explicit action that retains it. A local { "path" } import moves nothing and discloses nothing.',
14791
14914
  inputSchema: importScreenshotAdvertisedShape,
14792
- outputSchema: importScreenshotOutputSchema,
14915
+ outputSchema: importScreenshotToolOutputSchema,
14793
14916
  annotations: ADDITIVE_WRITE,
14794
14917
  _meta: toolMeta(deps, "import_screenshot", "Importing attached screenshot\u2026", "Attached screenshot imported", {
14795
14918
  "openai/fileParams": ["file"]
@@ -14801,9 +14924,9 @@ function registerTools(server, deps) {
14801
14924
  "delete_assets",
14802
14925
  {
14803
14926
  title: "Delete uploaded assets",
14804
- description: "Clean up after delivery: permanently removes the private screenshots, rendered panels and generated bundles ShotOps is holding for this account. Offer it once the user has their files, and call it only when they explicitly ask \u2014 this is irreversible. It cannot reach another account\u2019s files, and it refuses any asset still referenced by a live Project, production operation, share or preview.",
14927
+ description: "Clean up after delivery: permanently removes the private screenshots, rendered panels and generated bundles ShotOps is holding for this account. Offer it once the user has their files, and call it only when they explicitly ask \u2014 this is irreversible. Offer only UNREFERENCED temporary assets: anything a live Project, production operation, share or preview still depends on is refused, so proposing one is proposing a failure. It cannot reach another account\u2019s files. Cleanup is never a step that has to happen \u2014 an asset left alone simply expires.",
14805
14928
  inputSchema: deleteAssetsShape,
14806
- outputSchema: deleteAssetsOutputSchema,
14929
+ outputSchema: deleteAssetsToolOutputSchema,
14807
14930
  annotations: IDEMPOTENT_DESTRUCTIVE_WRITE,
14808
14931
  _meta: toolMeta(deps, "delete_assets", "Deleting ShotOps assets\u2026", "ShotOps assets deleted")
14809
14932
  },
@@ -14813,7 +14936,7 @@ function registerTools(server, deps) {
14813
14936
  "account_status",
14814
14937
  {
14815
14938
  title: "Check what this connection can do",
14816
- description: 'Ask whether this connection may produce store-ready screenshots \u2014 full-resolution panels and the fastlane bundle \u2014 before starting a render that might be refused. It reports whether an account is signed in, its plan, trial and remaining ShotOps credits, what a PREVIEW costs here, and \u2014 the field to branch on \u2014 whether production output is available: full-resolution render_strip and render_project, and every emit_bundle form. `production.available: false` comes with a machine-readable `nextStep` (sign_in, choose_plan, upgrade or retry) and a sentence to relay to the user; do not retry the production call until that step is done. CALL THIS FIRST when the user asks for a fastlane bundle, final panels, or "the real files" and you do not already know this connection can produce them \u2014 a refusal after a long render wastes their time, and this call is free: it spends no credits, uses up no free preview, uploads nothing, and writes nothing. Previewing never depends on it, so keep rendering previews regardless.',
14939
+ description: 'Ask whether this connection may produce store-ready screenshots \u2014 full-resolution panels and the fastlane bundle \u2014 before starting a render that might be refused. It reports whether an account is signed in, its plan, trial and remaining ShotOps cloud credits, what a PREVIEW costs here, and \u2014 the field to branch on \u2014 whether production output is available: full-resolution render_strip and render_project, and every emit_bundle form. `production.available: false` comes with a machine-readable `nextStep` (sign_in, choose_plan, upgrade or retry) and a sentence to relay to the user; do not retry the production call until that step is done. CALL THIS FIRST when the user asks for a fastlane bundle, final panels, or "the real files" and you do not already know this connection can produce them \u2014 a refusal after a long render wastes their time, and this call is free: it spends no cloud credits, uses up no free preview, uploads nothing, and writes nothing. Previewing never depends on it, so keep rendering previews regardless.',
14817
14940
  inputSchema: accountStatusShape,
14818
14941
  outputSchema: accountStatusOutputSchema,
14819
14942
  annotations: READ_ONLY,
@@ -15543,7 +15666,17 @@ var init_controlPlaneContract = __esm({
15543
15666
  function controlPlaneUrl() {
15544
15667
  return (process.env.SHOTOPS_CONTROL_PLANE_URL || DEFAULT_CONTROL_PLANE_URL).replace(/\/+$/, "");
15545
15668
  }
15546
- async function callOp(token, op, args = {}) {
15669
+ function retryAfterMs(res, attempt) {
15670
+ const header = res.headers.get("retry-after");
15671
+ if (header) {
15672
+ const seconds = Number(header);
15673
+ if (Number.isFinite(seconds) && seconds >= 0) return Math.min(seconds * 1e3, 3e4);
15674
+ const at = Date.parse(header);
15675
+ if (Number.isFinite(at)) return Math.min(Math.max(at - Date.now(), 0), 3e4);
15676
+ }
15677
+ return Math.min(1e3 * 2 ** attempt, 8e3);
15678
+ }
15679
+ async function callOp(token, op, args = {}, attempt = 0) {
15547
15680
  const res = await fetch(controlPlaneUrl(), {
15548
15681
  method: "POST",
15549
15682
  headers: {
@@ -15564,6 +15697,15 @@ async function callOp(token, op, args = {}) {
15564
15697
  if (res.status === 401) {
15565
15698
  throw new Error("control plane rejected the token \u2014 check SHOTOPS_TOKEN, or mint a new one in ShotOps.");
15566
15699
  }
15700
+ if (res.status === 429) {
15701
+ if (attempt < RATE_LIMIT_RETRIES) {
15702
+ await sleep(retryAfterMs(res, attempt));
15703
+ return await callOp(token, op, args, attempt + 1);
15704
+ }
15705
+ throw new Error(
15706
+ `ShotOps is refusing new requests right now (429 after ${RATE_LIMIT_RETRIES + 1} attempts). This is upstream load shedding, not your account or your token \u2014 wait a minute and run it again. A long multi-panel render is the usual trigger.`
15707
+ );
15708
+ }
15567
15709
  if (res.status === 426) {
15568
15710
  const minimum = parsed?.minimumVersion;
15569
15711
  const floor = typeof minimum === "string" && minimum.length > 0 ? ` (${minimum} or newer)` : "";
@@ -15694,7 +15836,8 @@ function connectHostedBridge(token) {
15694
15836
  generations: [...settlement.generationIds],
15695
15837
  outcome: settlement.outcome,
15696
15838
  committed: settlement.committed,
15697
- capped: settlement.capped
15839
+ capped: settlement.capped,
15840
+ receipt: settlement.receipt
15698
15841
  });
15699
15842
  return { balanceCredits: res.balanceCredits, refundedCredits: res.refundedCredits };
15700
15843
  },
@@ -15725,7 +15868,7 @@ function connectHostedBridge(token) {
15725
15868
  }
15726
15869
  };
15727
15870
  }
15728
- var DEFAULT_CONTROL_PLANE_URL, MAX_PASS_BODY_BYTES, ControlPlaneOperationError;
15871
+ var DEFAULT_CONTROL_PLANE_URL, MAX_PASS_BODY_BYTES, ControlPlaneOperationError, RATE_LIMIT_RETRIES, sleep;
15729
15872
  var init_hostedBridge = __esm({
15730
15873
  "src/hostedBridge.ts"() {
15731
15874
  "use strict";
@@ -15750,6 +15893,8 @@ var init_hostedBridge = __esm({
15750
15893
  this.requiredPlan = typeof body?.requiredPlan === "string" ? body.requiredPlan : null;
15751
15894
  }
15752
15895
  };
15896
+ RATE_LIMIT_RETRIES = 3;
15897
+ sleep = (ms) => new Promise((resolve10) => setTimeout(resolve10, ms));
15753
15898
  }
15754
15899
  });
15755
15900
 
@@ -15921,7 +16066,7 @@ var init_visionGeneration = __esm({
15921
16066
 
15922
16067
  // ../api/_lib/refineProjectContracts.ts
15923
16068
  import { z as z9 } from "zod";
15924
- var localeIds, MAX_DURABLE_MODEL_PROSE, hexSchema, currentColorSchema, shotPatchSchema, aiNumberSchema, captionStylePatchSchema, MAX_WIRE_GRADIENT_STOPS, gradientStopSchema, backgroundPatchSchema, stripBackgroundPatchSchema, framePatchSchema, currentCaptionStyleSchema, currentGradientStopSchema, currentStops, currentBackgroundSchema, currentStripBackgroundSchema, currentShotSchema, currentFramePatchSchema, localeCodeSchema, focusSchema, currentFrameSchema, captionOperationSchema, frameOperationSchema, frameIdSchema, assetIdSchema, structuralOperationSchema, attachmentSchema, visionScreenshotSchema, requestSchema, legacyFinalizeSchema, durableUsageSchema, durablePassSchema, durableStateDiffSchema, agentOutcomeSchema, durableFinalizeSchema, finalizeSchema, createSessionSchema, historySchema, REFINE_FIELD_LABELS;
16069
+ var localeIds, MAX_DURABLE_MODEL_PROSE, hexSchema, currentColorSchema, shotPatchSchema, aiNumberSchema, captionStylePatchSchema, MAX_WIRE_GRADIENT_STOPS, gradientStopSchema, backgroundPatchSchema, stripBackgroundPatchSchema, framePatchSchema, currentCaptionStyleSchema, currentGradientStopSchema, currentStops, currentBackgroundSchema, currentStripBackgroundSchema, currentShotSchema, currentFramePatchSchema, localeCodeSchema, focusSchema, currentFrameSchema, captionOperationSchema, frameOperationSchema, frameIdSchema, assetIdSchema, structuralOperationSchema, attachmentSchema, visionScreenshotSchema, requestSchema, legacyFinalizeSchema, durableUsageSchema, durablePassSchema, durableStateDiffSchema, agentOutcomeSchema, durableFinalizeSchema, revertSchema, finalizeSchema, createSessionSchema, historySchema, REFINE_FIELD_LABELS;
15925
16070
  var init_refineProjectContracts = __esm({
15926
16071
  "../api/_lib/refineProjectContracts.ts"() {
15927
16072
  "use strict";
@@ -16239,6 +16384,10 @@ var init_refineProjectContracts = __esm({
16239
16384
  projectId: z9.string().uuid(),
16240
16385
  sessionId: z9.string().uuid(),
16241
16386
  turnId: z9.string().uuid(),
16387
+ // #580 — the retry / clarification chain, and the ONLY receipt fact this door takes from the
16388
+ // browser. Every other figure on the receipt is re-read server-side from the ledger or derived
16389
+ // from the validated outcome; a parent id is a correlation the client alone knows.
16390
+ parentTurnId: z9.string().uuid().optional(),
16242
16391
  generationIds: z9.array(z9.string().uuid()).min(1).max(3),
16243
16392
  outcome: z9.enum(["completed", "cancelled", "failed"]),
16244
16393
  committed: z9.boolean(),
@@ -16283,7 +16432,15 @@ var init_refineProjectContracts = __esm({
16283
16432
  });
16284
16433
  }
16285
16434
  });
16286
- finalizeSchema = z9.union([durableFinalizeSchema, legacyFinalizeSchema]);
16435
+ revertSchema = z9.object({
16436
+ intent: z9.literal("revert"),
16437
+ projectId: z9.string().uuid(),
16438
+ /** The committed turn being undone. Becomes the new receipt's `parent_turn_id`. */
16439
+ turnId: z9.string().uuid(),
16440
+ /** The revert's own turn id, minted by the browser exactly as a turn id is. */
16441
+ revertTurnId: z9.string().uuid()
16442
+ });
16443
+ finalizeSchema = z9.union([revertSchema, durableFinalizeSchema, legacyFinalizeSchema]);
16287
16444
  createSessionSchema = z9.object({
16288
16445
  projectId: z9.string().uuid(),
16289
16446
  sessionId: z9.string().uuid(),
@@ -16340,6 +16497,7 @@ var init_refineProjectContracts = __esm({
16340
16497
  });
16341
16498
 
16342
16499
  // src/agentTurnAdapter.ts
16500
+ import { randomUUID as randomUUID3 } from "node:crypto";
16343
16501
  function recordEnvelope(existing, state) {
16344
16502
  const stored = existing != null && typeof existing === "object" && !Array.isArray(existing) ? { ...existing } : { record: 2 };
16345
16503
  return { ...stored, project: serializeProject(state) };
@@ -16578,6 +16736,7 @@ function applyAllowance(allowance, pass) {
16578
16736
  if (pass.remaining !== null) allowance.remaining = pass.remaining;
16579
16737
  }
16580
16738
  async function authorizePass(context, request, panelIds, rendered) {
16739
+ context.screenshotCount = request.screenshots.length + (request.composedPreview ? 1 : 0);
16581
16740
  const result = await context.runtime.plannerPass(context.userId, request, context.turnKey);
16582
16741
  applyAllowance(context.allowance, result.allowance);
16583
16742
  if (result.ok) {
@@ -16605,23 +16764,28 @@ async function runMcpProjectAgentTurn(deps, input, runtime) {
16605
16764
  if (!decision.allowed) {
16606
16765
  return decision.reason === "forbidden" ? { ...NOT_FOUND } : { error: "capability_denied", message: denial ?? "This action is not available." };
16607
16766
  }
16767
+ const turnId = randomUUID3();
16768
+ const turnKey = `turn_${turnId}`;
16769
+ const startedAt = input.startedAt ?? (/* @__PURE__ */ new Date()).toISOString();
16608
16770
  const allowance = { remaining: null, refunded: 0, settled: false };
16609
16771
  let storedRecord;
16610
16772
  let screenSources = null;
16611
16773
  const context = {
16612
16774
  userId: deps.userId,
16613
16775
  projectId: project2.id,
16614
- turnKey: input.turnKey,
16776
+ turnKey,
16777
+ screenshotCount: 0,
16615
16778
  instruction: input.instruction,
16616
16779
  focus: input.focus,
16617
16780
  allowance,
16618
16781
  runtime
16619
16782
  };
16620
16783
  const result = await runAgentTurn({
16784
+ turnId,
16621
16785
  instruction: input.instruction,
16622
16786
  focus: input.focus,
16623
16787
  requestedCycle: input.requestedCycle,
16624
- startedAt: input.startedAt,
16788
+ startedAt,
16625
16789
  cancelled: input.cancelled
16626
16790
  }, {
16627
16791
  loadProject: async () => {
@@ -16673,35 +16837,60 @@ async function runMcpProjectAgentTurn(deps, input, runtime) {
16673
16837
  },
16674
16838
  emit: async (event) => {
16675
16839
  if (event.type === "turn_completed") {
16676
- if (event.result.legacy.passes.length === 0) {
16840
+ const generationIds = event.result.legacy.passes.map((pass) => pass.generationId);
16841
+ if (generationIds.length > 0) {
16842
+ try {
16843
+ const settlement = await runtime.settleTurn({
16844
+ userId: deps.userId,
16845
+ projectId: project2.id,
16846
+ turnKey,
16847
+ generationIds,
16848
+ outcome: event.result.outcome,
16849
+ committed: event.result.committed,
16850
+ capped: event.result.legacy.status === "capped",
16851
+ receipt: {
16852
+ turnId: event.turnId,
16853
+ startedAt,
16854
+ completedAt: (/* @__PURE__ */ new Date()).toISOString(),
16855
+ screenshotCount: context.screenshotCount,
16856
+ instructionLength: input.instruction.length
16857
+ }
16858
+ });
16859
+ allowance.remaining = settlement.balanceCredits;
16860
+ allowance.refunded = settlement.refundedCredits;
16861
+ allowance.settled = true;
16862
+ } catch (error) {
16863
+ runtime.log("mcp_agent_turn_settlement_failed", {
16864
+ turnKey,
16865
+ ...safeFailureDiagnostic(error)
16866
+ });
16867
+ }
16868
+ } else {
16677
16869
  allowance.settled = true;
16678
- await runtime.emit(event);
16679
- return;
16680
16870
  }
16681
16871
  try {
16682
- const settlement = await runtime.settleTurn({
16872
+ await runtime.recordReceipt?.({
16873
+ turnId: event.turnId,
16683
16874
  userId: deps.userId,
16684
16875
  projectId: project2.id,
16685
- turnKey: input.turnKey,
16686
- generationIds: event.result.legacy.passes.map((pass) => pass.generationId),
16876
+ parentTurnId: null,
16687
16877
  outcome: event.result.outcome,
16688
16878
  committed: event.result.committed,
16689
- capped: event.result.legacy.status === "capped"
16879
+ generationIds,
16880
+ refundedCredits: allowance.refunded,
16881
+ startedAt,
16882
+ completedAt: (/* @__PURE__ */ new Date()).toISOString(),
16883
+ screenshotCount: context.screenshotCount,
16884
+ instructionLength: input.instruction.length
16690
16885
  });
16691
- allowance.remaining = settlement.balanceCredits;
16692
- allowance.refunded = settlement.refundedCredits;
16693
- allowance.settled = true;
16694
16886
  } catch (error) {
16695
- runtime.log("mcp_agent_turn_settlement_failed", {
16696
- turnKey: input.turnKey,
16697
- ...safeFailureDiagnostic(error)
16698
- });
16887
+ runtime.log("mcp_agent_turn_receipt_failed", { turnKey, ...safeFailureDiagnostic(error) });
16699
16888
  }
16700
16889
  }
16701
16890
  await runtime.emit(event);
16702
16891
  }
16703
16892
  });
16704
- return { ...result, projectId: project2.id, allowance };
16893
+ return { ...result, turnKey, projectId: project2.id, allowance };
16705
16894
  }
16706
16895
  var VISION_BASE64_BUDGET, NOT_FOUND;
16707
16896
  var init_agentTurnAdapter = __esm({
@@ -16737,7 +16926,10 @@ function writeErrorFrom(error) {
16737
16926
  function localAgentTurnRuntime(bridge) {
16738
16927
  return {
16739
16928
  plannerPass: (_userId, request, turnKey) => bridge.refinePass(request.projectId, request, turnKey),
16740
- settleTurn: ({ projectId, turnKey, generationIds, outcome, committed, capped }) => bridge.settleRefineTurn(projectId, { turnKey, generationIds, outcome, committed, capped }),
16929
+ // The receipt facts travel WITH settlement (#580): the server writes the one receipt for this
16930
+ // turn, keyed by the id the local coordinator ran under, and this machine — which holds no
16931
+ // Supabase credential — writes nothing durable at all.
16932
+ settleTurn: ({ projectId, turnKey, generationIds, outcome, committed, capped, receipt }) => bridge.settleRefineTurn(projectId, { turnKey, generationIds, outcome, committed, capped, receipt }),
16741
16933
  writeProjectRecord: async (projectId, expectedVersion, record6) => {
16742
16934
  try {
16743
16935
  const written = await bridge.updateProjectRecord(projectId, record6, expectedVersion);
@@ -16751,19 +16943,17 @@ function localAgentTurnRuntime(bridge) {
16751
16943
  };
16752
16944
  }
16753
16945
  async function runLocalRefineTurn(deps, bridge, input) {
16754
- const turnId = mintTurnId();
16755
16946
  try {
16756
16947
  const turn = await runMcpProjectAgentTurn(
16757
16948
  deps,
16758
16949
  {
16759
- turnKey: turnId,
16760
16950
  instruction: input.instruction,
16761
16951
  focus: input.focus ?? { kind: "project", label: "Whole project" },
16762
16952
  ...input.project ? { project: input.project } : {}
16763
16953
  },
16764
16954
  localAgentTurnRuntime(bridge)
16765
16955
  );
16766
- return refineProjectResult(turnId, turn);
16956
+ return refineProjectResult(turn);
16767
16957
  } catch {
16768
16958
  return refineProjectUnavailable("The Agent could not complete this turn. Nothing changed\u2014try again.");
16769
16959
  }
@@ -22414,7 +22604,7 @@ var PLACEHOLDER_PORT = 8765;
22414
22604
  var CLIENT_NAME = "ShotOps CLI";
22415
22605
  var LOGIN_TIMEOUT_MS = 5 * 60 * 1e3;
22416
22606
  function authOrigin() {
22417
- return studioOrigin();
22607
+ return authorityOrigin();
22418
22608
  }
22419
22609
  function base64url2(buf) {
22420
22610
  return buf.toString("base64url");
@@ -22738,7 +22928,7 @@ async function startStdioServer(token) {
22738
22928
  const localInstructions = token ? `${SERVER_INSTRUCTIONS}
22739
22929
 
22740
22930
  ## Local mode (this connection)
22741
- Rendering runs on THIS machine \u2014 free, no timeout ceiling. A hosted token is configured, so save_project / read_look / save_look also work (they read/write your hosted ShotOps account). refine_project runs the Agent turn HERE, rendering on this machine to inspect its own work, and asks ShotOps' servers only to run each AI pass \u2014 so it costs AI credits on your account, and one call commits at most one edit to the saved project. Never re-implement its edit by hand; ${LOCAL_INTAKE}` : `${SERVER_INSTRUCTIONS}
22931
+ Rendering runs on THIS machine \u2014 free, no timeout ceiling. A hosted token is configured, so save_project / read_look / save_look also work (they read/write your hosted ShotOps account). refine_project runs the Agent turn HERE, rendering on this machine to inspect its own work, and asks ShotOps' servers only to run each AI pass \u2014 so it costs AI cloud credits on your account, and one call commits at most one edit to the saved project. Never re-implement its edit by hand; ${LOCAL_INTAKE}` : `${SERVER_INSTRUCTIONS}
22742
22932
 
22743
22933
  ## Local mode (this connection)
22744
22934
  Rendering runs on THIS machine \u2014 free, no timeout ceiling. No hosted token is configured. A NEW save_project with local { "path": "..." } PNGs deliberately uploads one temporary private handoff and returns status:"pending_claim" + an openUrl: ALWAYS give that URL to the user. The user owns the project only after opening it and signing in within 7 days; no projectId exists before then. Passing an existing project requires SHOTOPS_TOKEN and is refused here. Ordinary render_strip / emit_bundle calls upload NOTHING, and an explicit request not to save means do not call save_project. read_look / save_look / read_project / render_project / refine_project / share links still require an account connection. ${LOCAL_INTAKE}`;