summer-engine 4.5.0 → 4.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +3 -2
  3. package/.codex-plugin/plugin.json +5 -4
  4. package/.cursor-plugin/plugin.json +4 -3
  5. package/.factory-plugin/plugin.json +4 -3
  6. package/CHANGELOG.md +50 -0
  7. package/README.md +3 -3
  8. package/commands/start.md +1 -1
  9. package/dist/cli/commands/api.d.ts +43 -0
  10. package/dist/cli/commands/api.js +191 -0
  11. package/dist/cli/commands/login.d.ts +2 -5
  12. package/dist/cli/commands/login.js +10 -81
  13. package/dist/cli/commands/setup.js +175 -32
  14. package/dist/cli/commands/token.d.ts +17 -0
  15. package/dist/cli/commands/token.js +25 -0
  16. package/dist/cli/index.js +4 -0
  17. package/dist/core/api-client.d.ts +5 -1
  18. package/dist/core/api-client.js +21 -0
  19. package/dist/core/capabilities/doctor.d.ts +1 -1
  20. package/dist/core/capabilities/doctor.js +1 -1
  21. package/dist/core/capabilities/game-task-plan.d.ts +2 -2
  22. package/dist/core/capabilities/navigation/open.d.ts +3 -0
  23. package/dist/core/capabilities/navigation/open.js +53 -0
  24. package/dist/core/capabilities/projects-local.js +2 -1
  25. package/dist/core/capabilities/runtime-control.d.ts +2 -2
  26. package/dist/core/capabilities/seeing/args.d.ts +4 -4
  27. package/dist/core/capabilities/tool-dispatch.js +37 -0
  28. package/dist/core/capabilities/ui-control.d.ts +2 -2
  29. package/dist/core/capabilities/view-set.d.ts +11 -0
  30. package/dist/core/capabilities/view-set.js +15 -0
  31. package/dist/core/capability-skew.d.ts +8 -0
  32. package/dist/core/capability-skew.js +18 -0
  33. package/dist/core/cli-session.d.ts +25 -0
  34. package/dist/core/cli-session.js +97 -0
  35. package/dist/core/engine-self-start.d.ts +20 -0
  36. package/dist/core/engine-self-start.js +108 -5
  37. package/dist/core/oauth.d.ts +15 -2
  38. package/dist/core/oauth.js +26 -3
  39. package/dist/core/ops/areas.d.ts +1 -1
  40. package/dist/core/ops/areas.js +5 -3
  41. package/dist/core/ops/engine-answer.js +5 -2
  42. package/dist/core/ops/engine.js +6 -0
  43. package/dist/core/ops/save-files.d.ts +2 -0
  44. package/dist/core/ops/save-files.js +13 -0
  45. package/dist/core/ops/studio-page.d.ts +22 -0
  46. package/dist/core/ops/studio-page.js +63 -0
  47. package/dist/core/sign-in-link.d.ts +38 -0
  48. package/dist/core/sign-in-link.js +106 -0
  49. package/dist/core/store.js +19 -1
  50. package/dist/core/summer-api.d.ts +114 -0
  51. package/dist/core/summer-api.js +281 -0
  52. package/dist/installer/agent-config.d.ts +29 -0
  53. package/dist/installer/agent-config.js +75 -11
  54. package/dist/installer/agent-detect.d.ts +44 -0
  55. package/dist/installer/agent-detect.js +166 -0
  56. package/dist/installer/agent-table.d.ts +19 -0
  57. package/dist/installer/agent-table.js +130 -0
  58. package/dist/installer/setup-error.d.ts +19 -0
  59. package/dist/installer/setup-error.js +34 -0
  60. package/dist/installer/version-check.d.ts +5 -0
  61. package/dist/installer/version-check.js +21 -0
  62. package/dist/mcp/ops-surface.d.ts +10 -0
  63. package/dist/mcp/ops-surface.js +18 -1
  64. package/dist/mcp/server.js +16 -4
  65. package/dist/mcp/tool-results.d.ts +9 -0
  66. package/dist/mcp/tool-results.js +64 -0
  67. package/dist/mcp/tool-surface.js +7 -1
  68. package/dist/mcp/tools/api-tools.d.ts +50 -0
  69. package/dist/mcp/tools/api-tools.js +65 -0
  70. package/dist/mcp/tools/local-project-tools.d.ts +14 -0
  71. package/dist/mcp/tools/local-project-tools.js +13 -2
  72. package/dist/mcp/tools/navigation-tools.js +1 -0
  73. package/dist/mcp/tools/scene-tools.js +7 -0
  74. package/dist/project-memory/agent-instructions.js +11 -3
  75. package/gemini-extension.json +3 -2
  76. package/library/references/mcp-tools-reference/mcp-tools-reference.md +15 -2
  77. package/library/references/mcp-tools-reference/resource.yaml +1 -1
  78. package/library/skills/README.md +6 -5
  79. package/library/skills/design-npc/SKILL.md +1 -1
  80. package/library/skills/design-npc/resource.yaml +2 -1
  81. package/library/skills/make-game/SKILL.md +4 -4
  82. package/library/skills/navigate-summer/SKILL.md +23 -2
  83. package/library/skills/navigate-summer/resource.yaml +5 -3
  84. package/library/skills/summer-api/SKILL.md +198 -0
  85. package/library/skills/summer-api/resource.yaml +38 -0
  86. package/library/skills/summer-assets/SKILL.md +1 -1
  87. package/library/skills/summer-assets/resource.yaml +3 -2
  88. package/library/skills/summer-router/SKILL.md +251 -0
  89. package/library/skills/summer-router/resource.yaml +35 -0
  90. package/library/tools/api-batch/resource.yaml +82 -0
  91. package/library/tools/api-estimate/resource.yaml +52 -0
  92. package/library/tools/api-job/resource.yaml +58 -0
  93. package/library/tools/api-models/resource.yaml +57 -0
  94. package/library/tools/api-run/resource.yaml +70 -0
  95. package/library/tools/view-set/GUIDE.md +3 -0
  96. package/library/tools/view-set/resource.yaml +78 -0
  97. package/package.json +3 -3
  98. package/registry/generated/aliases.json +7 -0
  99. package/registry/generated/counts.json +3 -3
  100. package/registry/generated/gemini-extension.json +3 -2
  101. package/registry/generated/index.json +317 -52
  102. package/registry/generated/marketplace.claude.json +1 -1
  103. package/registry/generated/plugin.claude-directory.json +4 -3
  104. package/registry/generated/plugin.claude.json +3 -2
  105. package/registry/generated/plugin.codex.json +5 -4
  106. package/registry/generated/plugin.cursor.json +4 -3
  107. package/registry/generated/plugin.factory.json +4 -3
  108. package/registry/generated/server.json +2 -2
  109. package/registry/generated/skills-index.md +6 -5
  110. package/registry/generated/skills-registry.json +29 -15
  111. package/registry/operations/legacy-names.json +30 -0
  112. package/registry/operations/public-catalog.json +9169 -4926
  113. package/library/skills/ai-characters-in-games/SKILL.md +0 -235
  114. package/library/skills/ai-characters-in-games/resource.yaml +0 -30
@@ -15,7 +15,7 @@
15
15
  */
16
16
  import { execSync } from "child_process";
17
17
  import { realpathSync } from "fs";
18
- import { resolve, sep } from "path";
18
+ import { basename, resolve, sep } from "path";
19
19
  import { EngineApiClient } from "./api-client.js";
20
20
  import { checkEngineHealth, findProjectRoot, listEngineInstances } from "./engine.js";
21
21
  import { CLOSED_EARLY, lastLaunch, LaunchLockBusyError, launchEditor, resolveRunBinary } from "./engine-launch.js";
@@ -48,7 +48,28 @@ export const SELF_START_RECOVERY = {
48
48
  closedEarly: (root) => `Summer Engine closed before it opened ${root}. Call again; if it closes again, open the project in Summer Engine to see why.`,
49
49
  launchFailed: (code) => `Summer Engine could not start (${code}). Repair it: npx -y summer-engine@latest install --yes, then call again.`,
50
50
  autoLaunchOff: (root) => `Open it with projects.open_local, or run: npx -y summer-engine@latest run ${root}`,
51
+ switchUnsupported: (open, wanted) => `Summer Engine has ${open} open, not ${wanted}, and this editor cannot switch games for an agent. ` +
52
+ `Update Summer Engine, or switch to ${wanted} in its games list and call again.`,
53
+ switchFailed: (wanted, reason) => `Summer Engine could not switch to ${wanted} (${reason}). Call again; if it fails again, open it from the editor's games list.`,
51
54
  };
55
+ /** The engine operation that makes a running editor open another game in
56
+ * the same window (the editor's own switch, J16.1). */
57
+ export const SWITCH_PROJECT_OP = "SwitchProject";
58
+ /** How long the new editor has to register after a switch: it boots first
59
+ * and registers about 5 s after the operation returns locally, about 14 s
60
+ * on CI (J17), so the next tool never meets a half-open game. */
61
+ export const SWITCH_REGISTER_MS = 30_000;
62
+ /** While a game opens, the agent hears "Opening <game>…" this often. */
63
+ export const OPEN_PROGRESS_EVERY_MS = 3_000;
64
+ /** Says `message` now and every OPEN_PROGRESS_EVERY_MS until the returned stop is called. */
65
+ export function keepSaying(progress, message) {
66
+ if (!progress)
67
+ return () => { };
68
+ progress(message);
69
+ const timer = setInterval(() => progress(message), OPEN_PROGRESS_EVERY_MS);
70
+ timer.unref?.();
71
+ return () => clearInterval(timer);
72
+ }
52
73
  /** Projects this process launched, by root: when and which process, so a slow first start is waited on while it lives. */
53
74
  const launched = new Map();
54
75
  /** The answer just given: a caller's immediate retry (withEngine retries a
@@ -104,17 +125,36 @@ function canonicalPath(path) {
104
125
  export function hasNoDisplay(env = process.env, platform = process.platform) {
105
126
  return platform === "linux" && !env.DISPLAY?.trim() && !env.WAYLAND_DISPLAY?.trim();
106
127
  }
107
- /** A live editor with another project than `root`, named for the person. */
128
+ /** A live editor with another project than `root`, and its name for the person. */
108
129
  async function otherOpenProject(root) {
109
130
  for (const instance of await listEngineInstances()) {
110
131
  if (instance.resourceRoot === root)
111
132
  continue;
112
133
  if (await checkEngineHealth(instance.port)) {
113
- return instance.projectName ? `${instance.projectName} (${instance.resourceRoot})` : instance.resourceRoot;
134
+ return { instance, name: instance.projectName ? `${instance.projectName} (${instance.resourceRoot})` : instance.resourceRoot };
114
135
  }
115
136
  }
116
137
  return null;
117
138
  }
139
+ /** A registered editor with `root` open that answers health, polled until `ms` pass. */
140
+ async function waitForRegisteredEditor(root, ms) {
141
+ const deadline = Date.now() + ms;
142
+ while (true) {
143
+ for (const instance of await listEngineInstances()) {
144
+ if (instance.resourceRoot === root && (await checkEngineHealth(instance.port)))
145
+ return true;
146
+ }
147
+ if (Date.now() >= deadline)
148
+ return false;
149
+ await sleep(50);
150
+ }
151
+ }
152
+ /** The first op result of an /api/ops answer, or null when it has none. */
153
+ function firstOpResult(answer) {
154
+ const results = answer?.results;
155
+ const first = Array.isArray(results) ? results[0] : null;
156
+ return first && typeof first === "object" ? first : null;
157
+ }
118
158
  async function connectWithin(selection, connect, ms) {
119
159
  const deadline = Date.now() + ms;
120
160
  while (true) {
@@ -173,8 +213,10 @@ export async function openProjectInEditor(root, selection, options = {}, deps =
173
213
  if (hasNoDisplay(env))
174
214
  throw failure("Summer Engine cannot open a window here.", SELF_START_RECOVERY.noDisplay);
175
215
  const open = await otherOpenProject(root);
216
+ if (open && options.switchOpen)
217
+ return switchOpenEditor(open, root, selection, connect, log, left, deps);
176
218
  if (open)
177
- throw failure("Summer Engine has another project open.", SELF_START_RECOVERY.otherProject(open, root));
219
+ throw failure("Summer Engine has another project open.", SELF_START_RECOVERY.otherProject(open.name, root));
178
220
  const binary = resolveRunBinary(undefined, env);
179
221
  if (binary.binary === null) {
180
222
  // A build named in SUMMER_BIN that is missing is not "not installed": installing would not change it.
@@ -197,6 +239,16 @@ export async function openProjectInEditor(root, selection, options = {}, deps =
197
239
  }
198
240
  launched.delete(root);
199
241
  log(`no editor has ${root} open: launching it in the background`);
242
+ const stop = keepSaying(deps.progress, `Opening ${basename(root)}…`);
243
+ try {
244
+ return await launchAndConnect(root, selection, connect, log, deadline, left, deps, failure);
245
+ }
246
+ finally {
247
+ stop();
248
+ }
249
+ }
250
+ async function launchAndConnect(root, selection, connect, log, deadline, left, deps, failure) {
251
+ const now = deps.now ?? Date.now;
200
252
  let result;
201
253
  try {
202
254
  result = await (deps.launch ?? launchEditor)({
@@ -236,6 +288,57 @@ export async function openProjectInEditor(root, selection, options = {}, deps =
236
288
  throw failure("Summer Engine could not start.", SELF_START_RECOVERY.launchFailed(result.code));
237
289
  }
238
290
  }
291
+ /**
292
+ * J16.1: the running editor opens `root` itself (one window: it starts the
293
+ * next editor and hands over), then the new editor is waited for through the
294
+ * registry and connected. An editor without the switch operation keeps the
295
+ * old answer: it names the open game.
296
+ */
297
+ async function switchOpenEditor(open, root, selection, connect, log, left, deps) {
298
+ const reason = `${open.name} is open`;
299
+ const failure = (message, recovery) => new EngineNotRunningError(message, reason, recovery);
300
+ log(`switching the editor from ${open.name} to ${root}`);
301
+ // The agent hears at once and every few seconds; the new editor needs a while (J16.5, J17).
302
+ const stop = keepSaying(deps.progress, `Opening ${basename(root)}…`);
303
+ try {
304
+ return await switchAndConnect(root, selection, connect, left, deps, open, failure);
305
+ }
306
+ finally {
307
+ stop();
308
+ }
309
+ }
310
+ async function switchAndConnect(root, selection, connect, left, deps, open, failure) {
311
+ let answer;
312
+ try {
313
+ const current = await connect({ instanceId: open.instance.instanceId });
314
+ answer = await current.executeOps([{ op: SWITCH_PROJECT_OP, path: root }], undefined, Math.max(5_000, left()));
315
+ }
316
+ catch (error) {
317
+ const message = error.message ?? String(error);
318
+ if (/unknown op|not supported|unsupported|engine_lacks_op/i.test(message)) {
319
+ throw failure("Summer Engine has another project open.", SELF_START_RECOVERY.switchUnsupported(open.name, root));
320
+ }
321
+ throw failure("Summer Engine could not switch games.", SELF_START_RECOVERY.switchFailed(root, message));
322
+ }
323
+ const result = firstOpResult(answer);
324
+ if (result && result.ok === false) {
325
+ const error = String(result.error ?? result.message ?? "the editor refused");
326
+ if (/unknown op|not supported|unsupported/i.test(error) || result.failure_reason === "engine_lacks_op") {
327
+ throw failure("Summer Engine has another project open.", SELF_START_RECOVERY.switchUnsupported(open.name, root));
328
+ }
329
+ throw failure("Summer Engine could not switch games.", SELF_START_RECOVERY.switchFailed(root, error));
330
+ }
331
+ // The new editor boots, then registers (about 5 s); a first open that
332
+ // imports assets answers health later: "switching", call again.
333
+ if (!(await waitForRegisteredEditor(root, Math.min(deps.switchWaitMs ?? SWITCH_REGISTER_MS, left())))) {
334
+ launched.set(root, { at: Date.now() });
335
+ throw failure("Summer Engine is switching to the project.", SELF_START_RECOVERY.starting(root));
336
+ }
337
+ const client = await connectWithin({ ...selection, instanceId: undefined, projectPath: root }, connect, Math.min(5_000, left()));
338
+ if (client)
339
+ return client;
340
+ throw failure("Summer Engine is starting.", SELF_START_RECOVERY.starting(root));
341
+ }
239
342
  /**
240
343
  * Open the session's project and return a connected client, or throw an
241
344
  * EngineNotRunningError whose recovery is the one next step for this case.
@@ -273,7 +376,7 @@ async function openSessionProject(selection, notRunning, deps) {
273
376
  if (env[AUTO_LAUNCH_ENV] === "0") {
274
377
  const open = await otherOpenProject(root);
275
378
  if (open)
276
- throw failure("Summer Engine has another project open.", SELF_START_RECOVERY.otherProject(open, root));
379
+ throw failure("Summer Engine has another project open.", SELF_START_RECOVERY.otherProject(open.name, root));
277
380
  if (resolveRunBinary(undefined, env).binary === null)
278
381
  throw failure("Summer Engine is not installed.", SELF_START_RECOVERY.notInstalledHere);
279
382
  throw failure(notRunning.message, SELF_START_RECOVERY.autoLaunchOff(root));
@@ -72,7 +72,20 @@ export declare function runStoreLogin(deps: StoreLoginDependencies): Promise<Sto
72
72
  export declare function readStoredOAuthToken(): Promise<StoredOAuthToken | null>;
73
73
  /**
74
74
  * A current store access token: the stored one, refreshed when it expires
75
- * within a minute.
75
+ * within a minute. `force` refreshes now: the sign-in server refuses the
76
+ * refresh once the person revoked the app, so it checks a token the API
77
+ * refused.
76
78
  */
77
- export declare function getStoreAccessToken(deps?: OAuthDependencies): Promise<string>;
79
+ export declare function getStoreAccessToken(deps?: OAuthDependencies, options?: {
80
+ force?: boolean;
81
+ }): Promise<string>;
82
+ /** The sign-in errors that mean the stored sign-in is gone (revoked or expired), not a server or network failure. */
83
+ export declare const SIGN_IN_GONE: Set<string>;
84
+ export declare const isSignInGone: (error: unknown) => boolean;
85
+ /**
86
+ * Whether this machine's store sign-in still works, checked with the sign-in
87
+ * server (one refresh). A server or network failure counts as signed in, so
88
+ * an outage never starts a new sign-in.
89
+ */
90
+ export declare function storeSignInWorks(deps?: OAuthDependencies): Promise<boolean>;
78
91
  export {};
@@ -287,14 +287,16 @@ export async function readStoredOAuthToken() {
287
287
  }
288
288
  /**
289
289
  * A current store access token: the stored one, refreshed when it expires
290
- * within a minute.
290
+ * within a minute. `force` refreshes now: the sign-in server refuses the
291
+ * refresh once the person revoked the app, so it checks a token the API
292
+ * refused.
291
293
  */
292
- export async function getStoreAccessToken(deps = defaultDependencies) {
294
+ export async function getStoreAccessToken(deps = defaultDependencies, options = {}) {
293
295
  const stored = await readStoredOAuthToken();
294
296
  if (!stored) {
295
297
  throw new OAuthError("store_login_required", "This machine is not signed in to the Summer store.", 'Recovery: run "summer login --store" in a terminal and approve access in the browser, then retry.');
296
298
  }
297
- if (Date.parse(stored.expiresAt) - deps.now() > REFRESH_MARGIN_MS)
299
+ if (!options.force && Date.parse(stored.expiresAt) - deps.now() > REFRESH_MARGIN_MS)
298
300
  return stored.accessToken;
299
301
  if (!stored.refreshToken) {
300
302
  throw new OAuthError("store_login_expired", "The Summer store sign-in expired.", 'Recovery: run "summer login --store --force", then retry.');
@@ -311,3 +313,24 @@ export async function getStoreAccessToken(deps = defaultDependencies) {
311
313
  await writeStoreJson(OAUTH_TOKEN_FILE, next);
312
314
  return next.accessToken;
313
315
  }
316
+ /** The sign-in errors that mean the stored sign-in is gone (revoked or expired), not a server or network failure. */
317
+ export const SIGN_IN_GONE = new Set(["store_login_required", "store_login_expired", "oauth_grant_invalid", "oauth_token_refused", "oauth_token_invalid"]);
318
+ export const isSignInGone = (error) => error instanceof OAuthError && SIGN_IN_GONE.has(error.code);
319
+ /**
320
+ * Whether this machine's store sign-in still works, checked with the sign-in
321
+ * server (one refresh). A server or network failure counts as signed in, so
322
+ * an outage never starts a new sign-in.
323
+ */
324
+ export async function storeSignInWorks(deps = defaultDependencies) {
325
+ if (!(await readStoredOAuthToken()))
326
+ return false;
327
+ try {
328
+ await getStoreAccessToken(deps, { force: true });
329
+ return true;
330
+ }
331
+ catch (error) {
332
+ if (isSignInGone(error))
333
+ return false;
334
+ return true;
335
+ }
336
+ }
@@ -7,5 +7,5 @@
7
7
  * Areas with operations today: each is one `summer <area> <action>` command
8
8
  * (src/cli/commands/ops.ts). CLI command words stay out of this list.
9
9
  */
10
- export declare const CLI_AREAS: readonly ["team", "profile", "docs", "assets", "makers", "jobs", "projects", "plans", "games", "store", "jams", "pricing", "analytics", "experiments", "marketing", "content", "players", "economy", "inbox", "apps", "studio", "chats", "stories", "plugins", "engine", "account"];
10
+ export declare const CLI_AREAS: readonly ["team", "profile", "docs", "assets", "makers", "jobs", "projects", "plans", "games", "store", "jams", "pricing", "analytics", "experiments", "marketing", "content", "players", "economy", "inbox", "apps", "studio", "chats", "stories", "plugins", "engine", "account", "router", "board"];
11
11
  export declare function isLocalOp(op: string): boolean;
@@ -10,7 +10,7 @@
10
10
  export const CLI_AREAS = [
11
11
  "team", "profile", "docs", "assets", "makers", "jobs", "projects", "plans", "games", "store", "jams", "pricing",
12
12
  "analytics", "experiments", "marketing", "content", "players", "economy", "inbox", "apps", "studio", "chats", "stories",
13
- "plugins", "engine", "account",
13
+ "plugins", "engine", "account", "router", "board",
14
14
  ];
15
15
  /**
16
16
  * Operations that run in this package: everything in `engine`, the library
@@ -21,10 +21,12 @@ export const CLI_AREAS = [
21
21
  * summer.items.json, and making a
22
22
  * piece of content from footage on this computer (content.make; Summer's
23
23
  * server keeps the request it reports to), and creating and opening a
24
- * project on this computer (projects.create_local, projects.open_local).
24
+ * project on this computer (projects.create_local, projects.open_local),
25
+ * and the Summer API calls made with the Summer sign-in on this computer
26
+ * (models.list, models.estimate, models.run, models.batch, models.job).
25
27
  * Everything else runs on the hosted server.
26
28
  */
27
- const LOCAL_OPS = new Set(["docs.search", "docs.read", "docs.feedback", "docs.plan", "docs.engine_api", "studio.open", "apps.dock", "apps.publish_folder", "store.upload_file", "store.go_live", "store.set_details", "economy.sync_items", "content.make", "projects.create_local", "projects.open_local"]);
29
+ const LOCAL_OPS = new Set(["docs.search", "docs.read", "docs.feedback", "docs.plan", "docs.engine_api", "studio.open", "apps.dock", "apps.publish_folder", "store.upload_file", "store.go_live", "store.set_details", "economy.sync_items", "content.make", "projects.create_local", "projects.open_local", "models.list", "models.estimate", "models.run", "models.batch", "models.job"]);
28
30
  export function isLocalOp(op) {
29
31
  return op.startsWith("engine.") || LOCAL_OPS.has(op);
30
32
  }
@@ -73,8 +73,11 @@ export function engineFailure(text, op, options) {
73
73
  if (code === "engine_lacks_op" || reason === "engine_lacks_op" || reason === "engine_lacks_events" || /engine_lacks_op/.test(text)) {
74
74
  return errorAnswer("engine_lacks_op", message, { next: { say: "Update Summer Engine." }, details });
75
75
  }
76
- if (code && PASSED_CODES.has(code))
77
- return errorAnswer(code, message, { details, ...(code === "invalid_input" ? { next: describe } : {}) });
76
+ if (code && PASSED_CODES.has(code)) {
77
+ // A signed-out tool answers with a sign-in link (mcp/tool-results.ts withSignInLink): the next step says it.
78
+ const signIn = code === "not_signed_in" && str(body?.signInUrl) ? { next: { say: str(body?.recovery) ?? `Sign-in link for the person: ${str(body?.signInUrl)}` } } : {};
79
+ return errorAnswer(code, message, { details, ...signIn, ...(code === "invalid_input" ? { next: describe } : {}) });
80
+ }
78
81
  if (!body && TRANSPORT.test(text)) {
79
82
  return options.effect === "read"
80
83
  ? errorAnswer("unavailable", message, { retry: "safe", next: { op: "engine.get_project_context" }, details })
@@ -221,6 +221,12 @@ export const ENGINE_OPS = {
221
221
  effect: "write",
222
222
  example: { input: { nodePath: "Player" }, note: "Show the person the player node." },
223
223
  },
224
+ "engine.view_set": {
225
+ title: "Show the person a view",
226
+ summary: "Switch what the person sees while you work: the view (3d, 2d, script, game, chat, home, studio), the right rail, the node or file in focus and the nodes to frame. Only the screen changes. Which view when: the navigate-summer skill.",
227
+ effect: "write",
228
+ example: { input: { view: "3d", rail: "scene", focus: "Track/Trees", frame: ["Track/Trees"] }, note: "Watch the level while you place trees." },
229
+ },
224
230
  "engine.batch": {
225
231
  title: "Run engine ops in one call",
226
232
  summary: "Send a list of raw engine ops (AddNode, SetProp, RunVerification...) in one call: one undo step unless an op must travel alone, and one save at the end. Ask for receipt summary on long lists. For computed edits, engine.run_script is simpler.",
@@ -50,6 +50,8 @@ export interface FileSaverOptions {
50
50
  maxBytes?: number;
51
51
  }
52
52
  /** Saves the files of generate, assets.get and jobs.wait answers; one per session (it remembers each job's `out`). */
53
+ /** Summer's own stores: a game's .summer/local (checkpoints, project id) and ~/.summer (sign-in, runtime). A save never lands there. */
54
+ export declare function inSummerStore(path: string): boolean;
53
55
  export declare class FileSaver {
54
56
  private readonly options;
55
57
  private readonly jobs;
@@ -210,6 +210,17 @@ async function claimName(temp, dir, base, ext) {
210
210
  throw new Error(`no free file name for ${base}${ext} in ${dir}`);
211
211
  }
212
212
  /** Saves the files of generate, assets.get and jobs.wait answers; one per session (it remembers each job's `out`). */
213
+ /** Summer's own stores: a game's .summer/local (checkpoints, project id) and ~/.summer (sign-in, runtime). A save never lands there. */
214
+ export function inSummerStore(path) {
215
+ // Windows paths ignore case: C:\Users\me\.Summer is the same folder.
216
+ const fold = (value) => (process.platform === "win32" ? value.toLowerCase() : value);
217
+ const full = fold(resolve(path));
218
+ const home = fold(join(homedir(), ".summer"));
219
+ if (full === home || full.startsWith(home + sep))
220
+ return true;
221
+ const parts = full.split(/[\\/]/).map((part) => part.toLowerCase());
222
+ return parts.some((part, index) => part === ".summer" && parts[index + 1] === "local");
223
+ }
213
224
  export class FileSaver {
214
225
  options;
215
226
  jobs = new Map();
@@ -350,6 +361,8 @@ export class FileSaver {
350
361
  else {
351
362
  path = isAbsolute(out) ? out : resolve(this.cwd(), out);
352
363
  }
364
+ if (inSummerStore(path))
365
+ return { error: `out: ${out} is Summer's own folder (.summer/local or ~/.summer). Nothing was saved on this machine.` };
353
366
  const folder = /[\\/]$/.test(out) || !extname(path) || (await isDirectory(path));
354
367
  return folder ? { dir: path } : { dir: dirname(path), file: basename(path) };
355
368
  }
@@ -0,0 +1,22 @@
1
+ import type { EngineCapabilities } from "../capability-skew.js";
2
+ import { type Answer } from "./contract.js";
3
+ /**
4
+ * The Studio page ops a running Summer app answers itself, through its own
5
+ * Studio tab (engine POST /api/studio, advertised as
6
+ * capabilities.studioPage): the same op names, input and answers as on
7
+ * Summer's server, with no store sign-in. When the app cannot answer (no
8
+ * engine, an older build, or no Studio tab: `no_tab`), the caller sends the op
9
+ * to Summer's server as before.
10
+ */
11
+ export declare const STUDIO_PAGE_OPS: ReadonlySet<string>;
12
+ export interface StudioPageClient {
13
+ getEngineCapabilities?(): EngineCapabilities | undefined;
14
+ studioPage(op: string, input: Record<string, unknown>): Promise<unknown>;
15
+ }
16
+ /** The page's answer as the one answer shape, or null for "send it to Summer's server". */
17
+ export declare function studioPageAnswer(envelope: unknown): Answer | null;
18
+ /**
19
+ * Run a Studio page op in the app's own Studio tab when the running app
20
+ * offers it; null when it does not (the op goes to Summer's server).
21
+ */
22
+ export declare function runInAppStudio(op: string, input: Record<string, unknown>, engine: () => Promise<StudioPageClient>): Promise<Answer | null>;
@@ -0,0 +1,63 @@
1
+ import { errorAnswer } from "./contract.js";
2
+ /**
3
+ * The Studio page ops a running Summer app answers itself, through its own
4
+ * Studio tab (engine POST /api/studio, advertised as
5
+ * capabilities.studioPage): the same op names, input and answers as on
6
+ * Summer's server, with no store sign-in. When the app cannot answer (no
7
+ * engine, an older build, or no Studio tab: `no_tab`), the caller sends the op
8
+ * to Summer's server as before.
9
+ */
10
+ export const STUDIO_PAGE_OPS = new Set(["studio.map", "studio.page", "studio.use_page", "studio.read_form", "studio.submit_form"]);
11
+ const CODES = new Set([
12
+ "invalid_input", "not_signed_in", "forbidden", "not_found", "conflict", "idempotency_conflict", "in_progress", "rate_limited",
13
+ "no_credits", "over_limit", "unsupported", "moved", "outcome_unknown", "unavailable",
14
+ ]);
15
+ const asRecord = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : null;
16
+ /** The page's answer as the one answer shape, or null for "send it to Summer's server". */
17
+ export function studioPageAnswer(envelope) {
18
+ const record = asRecord(envelope);
19
+ const first = asRecord(Array.isArray(record?.results) ? record.results[0] : null);
20
+ if (!first) {
21
+ return errorAnswer("outcome_unknown", "The Summer app did not say how the Studio page answered.", { next: { op: "studio.page" } });
22
+ }
23
+ if (first.ok === true) {
24
+ const result = asRecord(first.result) ?? {};
25
+ // An op that waits on the person (needs_approval) or runs on comes back as the answer itself.
26
+ if (result.status === "needs_approval" || result.status === "running")
27
+ return { ok: true, ...result };
28
+ return { ok: true, status: "done", result };
29
+ }
30
+ const reason = typeof first.failure_reason === "string" ? first.failure_reason : "unavailable";
31
+ if (reason === "no_tab")
32
+ return null;
33
+ const message = typeof first.error === "string" && first.error ? first.error : "The Studio page refused this.";
34
+ const code = reason === "timeout" ? "outcome_unknown" : CODES.has(reason) ? reason : "unavailable";
35
+ return errorAnswer(code, message, code === "outcome_unknown" || code === "conflict" ? { next: { op: "studio.page" } } : {});
36
+ }
37
+ /**
38
+ * Run a Studio page op in the app's own Studio tab when the running app
39
+ * offers it; null when it does not (the op goes to Summer's server).
40
+ */
41
+ export async function runInAppStudio(op, input, engine) {
42
+ if (!STUDIO_PAGE_OPS.has(op))
43
+ return null;
44
+ let client;
45
+ try {
46
+ client = await engine();
47
+ }
48
+ catch {
49
+ return null;
50
+ }
51
+ if (!client.getEngineCapabilities?.()?.studioPage?.ops.includes(op))
52
+ return null;
53
+ const { tabId: _tabId, ...rest } = input;
54
+ let envelope;
55
+ try {
56
+ envelope = await client.studioPage(op, rest);
57
+ }
58
+ catch (error) {
59
+ // Not acknowledged: nothing reached the page.
60
+ return errorAnswer("unavailable", `The Summer app did not take the Studio op: ${error instanceof Error ? error.message : String(error)}`, { retry: "safe" });
61
+ }
62
+ return studioPageAnswer(envelope);
63
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Sign-in links the MCP hands to the agent when a cloud tool needs a sign-in.
3
+ *
4
+ * An agent outside Summer (Claude Code, Codex, Cursor) cannot click a browser
5
+ * for the person, and `summer login` blocks a terminal. So when a tool answers
6
+ * `not_signed_in`, the long-lived MCP process starts the same `cli_session`
7
+ * sign-in `summer login` uses, without opening anything, and returns its link;
8
+ * the agent shows it, the person signs in, and the poll saves the token to
9
+ * ~/.summer. The next call works: no restart, no reinstall. A
10
+ * `store_login_required` answer gets the store's approval link the same way
11
+ * (core/oauth.ts runStoreLogin, loopback callback on this machine).
12
+ *
13
+ * One pending sign-in of each kind per process: repeated failures return the
14
+ * same link until it finishes or expires.
15
+ */
16
+ export type SignInKind = "account" | "store";
17
+ export interface SignInLinkDependencies {
18
+ gatewayUrl: () => Promise<string>;
19
+ randomId: () => string;
20
+ /** Waits for the account sign-in and saves it; resolves when done. */
21
+ waitForAccount: (gatewayUrl: string, sessionId: string, cancelled: () => Promise<boolean>) => Promise<unknown>;
22
+ /** Runs the store approval; calls `onUrl` with its link, resolves when approved. */
23
+ runStore: (onUrl: (url: string) => void) => Promise<unknown>;
24
+ /** The saved account token, if any. */
25
+ accountToken: () => Promise<string | null>;
26
+ now: () => number;
27
+ }
28
+ export declare class SignInLinks {
29
+ private readonly deps;
30
+ private pending;
31
+ constructor(deps?: SignInLinkDependencies);
32
+ /** The link for this kind of sign-in, starting the wait when none is pending; null when none can be made. */
33
+ link(kind: SignInKind): Promise<string | null>;
34
+ }
35
+ /** What the agent shows the person: the link, then what happens after. */
36
+ export declare function signInLinkText(kind: SignInKind, url: string): string;
37
+ /** The sign-in kind a tool error code asks for, if any. */
38
+ export declare function signInKindForCode(code: string | null): SignInKind | null;
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Sign-in links the MCP hands to the agent when a cloud tool needs a sign-in.
3
+ *
4
+ * An agent outside Summer (Claude Code, Codex, Cursor) cannot click a browser
5
+ * for the person, and `summer login` blocks a terminal. So when a tool answers
6
+ * `not_signed_in`, the long-lived MCP process starts the same `cli_session`
7
+ * sign-in `summer login` uses, without opening anything, and returns its link;
8
+ * the agent shows it, the person signs in, and the poll saves the token to
9
+ * ~/.summer. The next call works: no restart, no reinstall. A
10
+ * `store_login_required` answer gets the store's approval link the same way
11
+ * (core/oauth.ts runStoreLogin, loopback callback on this machine).
12
+ *
13
+ * One pending sign-in of each kind per process: repeated failures return the
14
+ * same link until it finishes or expires.
15
+ */
16
+ import { randomUUID } from "crypto";
17
+ import { getAuthToken } from "./auth.js";
18
+ import { cliSessionLoginUrl, pollCliSession, POLL_TIMEOUT_MS } from "./cli-session.js";
19
+ import { resolveGatewayUrl } from "./config.js";
20
+ import { runStoreLogin } from "./oauth.js";
21
+ /** Unref'd timers: a pending sign-in never keeps a finished process alive. */
22
+ function sleep(ms) {
23
+ return new Promise((resolve) => setTimeout(resolve, ms).unref());
24
+ }
25
+ const defaultDependencies = {
26
+ gatewayUrl: resolveGatewayUrl,
27
+ randomId: randomUUID,
28
+ waitForAccount: (gatewayUrl, sessionId, cancelled) => pollCliSession(gatewayUrl, sessionId, { fetch, sleep, now: Date.now, log: () => { }, cancelled }),
29
+ runStore: (onUrl) => runStoreLogin({
30
+ fetch: (input, init) => globalThis.fetch(input, init),
31
+ now: Date.now,
32
+ log: () => { },
33
+ openUrl: async (url) => onUrl(url),
34
+ }),
35
+ accountToken: getAuthToken,
36
+ now: Date.now,
37
+ };
38
+ const STORE_LINK_WAIT_MS = 10_000;
39
+ export class SignInLinks {
40
+ deps;
41
+ pending = new Map();
42
+ constructor(deps = defaultDependencies) {
43
+ this.deps = deps;
44
+ }
45
+ /** The link for this kind of sign-in, starting the wait when none is pending; null when none can be made. */
46
+ async link(kind) {
47
+ const current = this.pending.get(kind);
48
+ if (current && !current.done && this.deps.now() - current.startedAt < POLL_TIMEOUT_MS) {
49
+ return current.url;
50
+ }
51
+ const pending = { url: Promise.resolve(null), startedAt: this.deps.now(), done: false };
52
+ const finish = () => {
53
+ pending.done = true;
54
+ };
55
+ if (kind === "account") {
56
+ pending.url = (async () => {
57
+ const gatewayUrl = await this.deps.gatewayUrl();
58
+ const sessionId = this.deps.randomId();
59
+ // Another sign-in (the app, `summer login`) may save a new token
60
+ // first: stop then. A refused old token does not count.
61
+ const before = await this.deps.accountToken().catch(() => null);
62
+ const cancelled = async () => {
63
+ const now = await this.deps.accountToken().catch(() => null);
64
+ return Boolean(now) && now !== before;
65
+ };
66
+ void this.deps.waitForAccount(gatewayUrl, sessionId, cancelled).then(finish, finish);
67
+ return cliSessionLoginUrl(gatewayUrl, sessionId);
68
+ })().catch(() => {
69
+ finish();
70
+ return null;
71
+ });
72
+ }
73
+ else {
74
+ pending.url = new Promise((resolve) => {
75
+ const timer = setTimeout(() => resolve(null), STORE_LINK_WAIT_MS);
76
+ timer.unref();
77
+ void this.deps
78
+ .runStore((url) => {
79
+ clearTimeout(timer);
80
+ resolve(url);
81
+ })
82
+ .then(finish, finish)
83
+ .finally(() => resolve(null));
84
+ });
85
+ }
86
+ this.pending.set(kind, pending);
87
+ const url = await pending.url;
88
+ if (url === null)
89
+ finish();
90
+ return url;
91
+ }
92
+ }
93
+ /** What the agent shows the person: the link, then what happens after. */
94
+ export function signInLinkText(kind, url) {
95
+ return kind === "account"
96
+ ? `Sign-in link for the person: ${url}\nAsk them to open it and sign in to Summer. When they are done, call this tool again: it works without a restart or reinstall. Engine tools work without signing in.`
97
+ : `Store approval link for the person: ${url}\nAsk them to open it on this computer and approve Summer. When they are done, call this tool again: it works without a restart.`;
98
+ }
99
+ /** The sign-in kind a tool error code asks for, if any. */
100
+ export function signInKindForCode(code) {
101
+ if (code === "not_signed_in")
102
+ return "account";
103
+ if (code === "store_login_required")
104
+ return "store";
105
+ return null;
106
+ }
@@ -39,6 +39,24 @@ async function hardenMode(path, mode) {
39
39
  throw error;
40
40
  }
41
41
  }
42
+ /**
43
+ * Windows refuses a rename for a moment while another process has the file
44
+ * open (antivirus scanning the new file, the engine or a second summer reading
45
+ * the token): retry briefly, the way npm's write-file-atomic does.
46
+ */
47
+ async function renameIntoPlace(from, to) {
48
+ for (let attempt = 1;; attempt++) {
49
+ try {
50
+ return await rename(from, to);
51
+ }
52
+ catch (error) {
53
+ const code = error.code ?? "";
54
+ if (process.platform !== "win32" || attempt >= 10 || !["EPERM", "EACCES", "EBUSY"].includes(code))
55
+ throw error;
56
+ await new Promise((done) => setTimeout(done, 25 * attempt));
57
+ }
58
+ }
59
+ }
42
60
  export async function ensureSummerStore() {
43
61
  const dir = getSummerDir();
44
62
  try {
@@ -107,7 +125,7 @@ export async function writeStoreText(name, value) {
107
125
  flag: "wx",
108
126
  });
109
127
  await hardenMode(temporary, 0o600);
110
- await rename(temporary, destination);
128
+ await renameIntoPlace(temporary, destination);
111
129
  await hardenMode(destination, 0o600);
112
130
  }
113
131
  catch (error) {