@jxsuite/studio 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/dist/iframe-entry.js +622 -49
  2. package/dist/iframe-entry.js.map +15 -12
  3. package/dist/studio.css +1333 -0
  4. package/dist/studio.js +53069 -31746
  5. package/dist/studio.js.map +109 -74
  6. package/package.json +11 -9
  7. package/src/canvas/canvas-live-render.ts +21 -2
  8. package/src/canvas/canvas-render.ts +35 -16
  9. package/src/canvas/canvas-utils.ts +118 -72
  10. package/src/canvas/iframe-entry.ts +20 -0
  11. package/src/canvas/iframe-eval.ts +155 -0
  12. package/src/canvas/iframe-host.ts +131 -5
  13. package/src/canvas/iframe-inline-edit.ts +107 -1
  14. package/src/canvas/iframe-protocol.ts +43 -3
  15. package/src/canvas/iframe-render.ts +18 -0
  16. package/src/collab/collab-session.ts +23 -8
  17. package/src/collab/collab-state.ts +6 -3
  18. package/src/component-props.ts +104 -0
  19. package/src/editor/inline-edit-apply.ts +36 -1
  20. package/src/editor/inline-edit.ts +58 -1
  21. package/src/editor/shortcuts.ts +41 -12
  22. package/src/files/file-ops.ts +14 -0
  23. package/src/files/files.ts +53 -1
  24. package/src/grid/cell-editors.ts +288 -0
  25. package/src/grid/cell-popovers.ts +108 -0
  26. package/src/grid/csv-codec.ts +122 -0
  27. package/src/grid/edit-buffer.ts +490 -0
  28. package/src/grid/grid-controller.ts +417 -0
  29. package/src/grid/grid-layout.ts +83 -0
  30. package/src/grid/grid-open.ts +167 -0
  31. package/src/grid/grid-panel.ts +302 -0
  32. package/src/grid/grid-source.ts +210 -0
  33. package/src/grid/grid-view.ts +367 -0
  34. package/src/grid/schema-columns.ts +261 -0
  35. package/src/grid/sources/connector-source.ts +217 -0
  36. package/src/grid/sources/content-source.ts +542 -0
  37. package/src/grid/sources/csv-file-source.ts +247 -0
  38. package/src/grid/tabulator-tables.d.ts +120 -0
  39. package/src/panels/block-action-bar.ts +8 -3
  40. package/src/panels/chat-panel.ts +98 -0
  41. package/src/panels/data-grid.ts +9 -387
  42. package/src/panels/editors.ts +43 -17
  43. package/src/panels/events-panel.ts +137 -44
  44. package/src/panels/formula-workspace.ts +379 -0
  45. package/src/panels/frontmatter-fields.ts +231 -0
  46. package/src/panels/frontmatter-panel.ts +132 -0
  47. package/src/panels/git-panel.ts +1 -1
  48. package/src/panels/head-panel.ts +11 -175
  49. package/src/panels/properties-panel.ts +154 -144
  50. package/src/panels/right-panel.ts +9 -47
  51. package/src/panels/signals-panel.ts +115 -23
  52. package/src/panels/statement-editor.ts +710 -0
  53. package/src/panels/style-panel.ts +25 -1
  54. package/src/panels/stylebook-panel.ts +0 -2
  55. package/src/panels/tab-bar.ts +175 -6
  56. package/src/panels/tab-strip.ts +62 -6
  57. package/src/panels/toolbar.ts +37 -3
  58. package/src/services/ai-project-tools.ts +541 -0
  59. package/src/services/ai-session-store.ts +28 -0
  60. package/src/services/ai-system-prompt.ts +194 -24
  61. package/src/services/ai-tools.ts +88 -21
  62. package/src/services/automation.ts +16 -0
  63. package/src/services/document-assistant.ts +81 -11
  64. package/src/services/gated-registry.ts +72 -0
  65. package/src/services/live-preview.ts +0 -0
  66. package/src/services/preview-eval.ts +68 -0
  67. package/src/services/project-adoption.ts +31 -0
  68. package/src/site-context.ts +2 -0
  69. package/src/store.ts +4 -0
  70. package/src/studio.ts +83 -52
  71. package/src/tabs/patch-ops.ts +25 -0
  72. package/src/tabs/tab.ts +39 -1
  73. package/src/tabs/transact.ts +60 -13
  74. package/src/types.ts +12 -0
  75. package/src/ui/dynamic-slot.ts +272 -0
  76. package/src/ui/expression-editor.ts +423 -125
  77. package/src/ui/field-row.ts +5 -0
  78. package/src/ui/formula-catalog.ts +557 -0
  79. package/src/ui/formula-chips.ts +216 -0
  80. package/src/ui/formula-palette.ts +211 -0
  81. package/src/ui/layers.ts +40 -0
  82. package/src/ui/media-picker.ts +1 -1
  83. package/src/ui/panel-resize.ts +15 -1
  84. package/src/utils/preview-format.ts +26 -0
  85. package/src/view.ts +4 -4
  86. package/src/workspace/workspace.ts +14 -0
@@ -0,0 +1,541 @@
1
+ /**
2
+ * Ai-project-tools.ts — project-level tools for the AI assistant.
3
+ *
4
+ * Two tiers registered here (document tools live in ai-tools.ts):
5
+ *
6
+ * - Bootstrap (no project open): `create_project` scaffolds through the platform's shared
7
+ * `createProject` pipeline (`@jxsuite/create` server-side) and adopts the result into this
8
+ * window; `list_starters` enumerates starter templates.
9
+ * - Cross-file (project open): `list_files` / `read_file` / `write_file` / `search_files` operate
10
+ * through the platform adapter without touching the tab strip, so the agent can develop across
11
+ * many files. `write_file` pre-validates Jx documents (no undo exists for disk writes, so
12
+ * validation blocks instead of optimistically applying) and reconciles with open tabs.
13
+ *
14
+ * @license MIT
15
+ */
16
+
17
+ import { createToolDefinition } from "@jxsuite/ai/tools";
18
+ import type { ToolRegistry, ToolResult } from "@jxsuite/ai/tools";
19
+ import type { JxMutableNode } from "@jxsuite/schema/types";
20
+ import { getPlatform } from "../platform";
21
+ import { workspace } from "../workspace/workspace";
22
+ import type { Tab } from "../tabs/tab";
23
+ import { beginBatch, endBatch, isBatching } from "../tabs/transact";
24
+ import { translateValidationError } from "./ai-tools";
25
+ import { validateDoc } from "./jx-validate";
26
+ import { flagHardcodedTokens, formatTokenHints } from "./token-lint";
27
+
28
+ /** Directories the file tools never descend into or report. */
29
+ const EXCLUDED_DIRS = new Set(["node_modules", "dist", ".git", ".jx-cache"]);
30
+
31
+ /** Caps keeping tool results inside chat-context and localStorage budgets. */
32
+ const LIST_CAP = 200;
33
+ const SEARCH_CAP = 100;
34
+ const READ_CAP_BYTES = 48 * 1024;
35
+ const WRITE_CAP_BYTES = 256 * 1024;
36
+
37
+ const NOT_UNDOABLE = "(saved to disk; not undoable with Cmd+Z)";
38
+
39
+ /**
40
+ * Normalize a project-relative path, or return null when it escapes the project (absolute paths,
41
+ * `..` segments, drive letters). The server re-checks; this keeps the error actionable.
42
+ */
43
+ export function normalizeRelPath(path: unknown): string | null {
44
+ if (typeof path !== "string" || !path.trim()) {
45
+ return null;
46
+ }
47
+ let p = path.trim().replaceAll("\\", "/");
48
+ while (p.startsWith("./")) {
49
+ p = p.slice(2);
50
+ }
51
+ if (p.startsWith("/") || p.startsWith("~") || /^[A-Za-z]:/.test(p)) {
52
+ return null;
53
+ }
54
+ if (p.split("/").includes("..")) {
55
+ return null;
56
+ }
57
+ return p;
58
+ }
59
+
60
+ function pathError(path: unknown): ToolResult {
61
+ return {
62
+ success: false,
63
+ error: `Invalid path ${JSON.stringify(path)} — use a path relative to the project root (no leading "/", no "..").`,
64
+ };
65
+ }
66
+
67
+ /** Whether a project-relative path conventionally holds a Jx document. */
68
+ function isJxDocPath(path: string): boolean {
69
+ return /^(pages|layouts|components)\//.test(path) && path.endsWith(".json");
70
+ }
71
+
72
+ /** Structural sniff for Jx-document-shaped JSON values. */
73
+ function looksLikeJxDoc(value: unknown): boolean {
74
+ return (
75
+ typeof value === "object" &&
76
+ value !== null &&
77
+ !Array.isArray(value) &&
78
+ ("tagName" in value || "$id" in value || "$elements" in value)
79
+ );
80
+ }
81
+
82
+ export interface ProjectToolsCtx {
83
+ getTab: () => Tab | null;
84
+ validate?: (doc: unknown) => Promise<string[]>;
85
+ renderCheck?: (doc: unknown) => Promise<{ ok: true } | { ok: false; error: string }>;
86
+ /** Live getter — the project style appears only after a project is open/bootstrapped. */
87
+ getProjectStyle?: () => Record<string, string> | undefined;
88
+ /** The open tab whose `documentPath` equals the given project-relative path, if any. */
89
+ findOpenTab: (path: string) => Tab | null;
90
+ /** Reload an open tab's document from disk (files.ts `reloadFileInTab`). */
91
+ reloadTab: (path: string) => Promise<void>;
92
+ /** Open the project at an absolute root in this window (project-adoption.ts). */
93
+ adoptProject: (root: string) => Promise<void>;
94
+ /** Fired after adoption is verified — the session store re-keys the live chat here. */
95
+ onProjectAdopted?: (root: string) => void;
96
+ /** Fired after a successful `project.json` write so workspace/project state stay in sync. */
97
+ onProjectConfigWritten?: (config: object) => void;
98
+ }
99
+
100
+ /**
101
+ * Register the project-level tools into a tool registry.
102
+ *
103
+ * @param {Pick<ToolRegistry, "register">} registry
104
+ * @param {ProjectToolsCtx} ctx
105
+ */
106
+ export function registerProjectTools(
107
+ registry: Pick<ToolRegistry, "register">,
108
+ {
109
+ getTab,
110
+ validate = validateDoc,
111
+ renderCheck,
112
+ getProjectStyle,
113
+ findOpenTab,
114
+ reloadTab,
115
+ adoptProject,
116
+ onProjectAdopted,
117
+ onProjectConfigWritten,
118
+ }: ProjectToolsCtx,
119
+ ) {
120
+ // ── list_files ─────────────────────────────────────────────────────────
121
+
122
+ registry.register(
123
+ createToolDefinition({
124
+ name: "list_files",
125
+ description:
126
+ "List the project's files recursively (paths relative to the project root, with type and " +
127
+ "size). Use this to discover pages, layouts, components, content, data, and styles before " +
128
+ "reading or writing files. Build folders (node_modules, dist, .git) are excluded.",
129
+ parameters: {
130
+ type: "object",
131
+ properties: {
132
+ dir: {
133
+ type: "string",
134
+ description:
135
+ 'Directory to list, relative to the project root. Omit for the root (".").',
136
+ },
137
+ },
138
+ required: [],
139
+ },
140
+ async execute(args) {
141
+ const { dir } = args as { dir?: string };
142
+ const start = dir === undefined || dir === "" ? "." : normalizeRelPath(dir);
143
+ if (start === null) {
144
+ return pathError(dir);
145
+ }
146
+ const platform = getPlatform();
147
+ const entries: { path: string; type: string; size?: number }[] = [];
148
+ const queue = [start];
149
+ let truncated = false;
150
+ while (queue.length > 0 && !truncated) {
151
+ const current = queue.shift()!;
152
+ let children;
153
+ try {
154
+ children = await platform.listDirectory(current);
155
+ } catch {
156
+ continue;
157
+ }
158
+ for (const e of children) {
159
+ const name = e.name || e.path.split("/").pop() || "";
160
+ if (EXCLUDED_DIRS.has(name) || name.startsWith(".")) {
161
+ continue;
162
+ }
163
+ const path = e.path || (current === "." ? name : `${current}/${name}`);
164
+ if (entries.length >= LIST_CAP) {
165
+ truncated = true;
166
+ break;
167
+ }
168
+ entries.push({
169
+ path,
170
+ type: e.type,
171
+ ...(typeof e.size === "number" ? { size: e.size } : {}),
172
+ });
173
+ if (e.type === "directory") {
174
+ queue.push(path);
175
+ }
176
+ }
177
+ }
178
+ return {
179
+ success: true,
180
+ data: { entries, truncated },
181
+ summary: `Listed ${entries.length} entries under "${start}"${truncated ? ` (truncated at ${LIST_CAP})` : ""}.`,
182
+ };
183
+ },
184
+ }),
185
+ );
186
+
187
+ // ── read_file ──────────────────────────────────────────────────────────
188
+
189
+ registry.register(
190
+ createToolDefinition({
191
+ name: "read_file",
192
+ description:
193
+ "Read a project file's content by its project-relative path. Works for any text file " +
194
+ "(Jx documents, markdown, CSS, data). Large files are truncated.",
195
+ parameters: {
196
+ type: "object",
197
+ properties: {
198
+ path: {
199
+ type: "string",
200
+ description: 'Project-relative file path, e.g. "pages/about.json".',
201
+ },
202
+ },
203
+ required: ["path"],
204
+ },
205
+ async execute(args) {
206
+ const relPath = normalizeRelPath((args as { path: string }).path);
207
+ if (relPath === null) {
208
+ return pathError((args as { path: string }).path);
209
+ }
210
+ try {
211
+ const content = await getPlatform().readFile(relPath);
212
+ if (content.length > READ_CAP_BYTES) {
213
+ return {
214
+ success: true,
215
+ data: {
216
+ content: `${content.slice(0, READ_CAP_BYTES)}\n… [truncated: file is ${content.length} bytes, showing first ${READ_CAP_BYTES}]`,
217
+ truncated: true,
218
+ },
219
+ };
220
+ }
221
+ return { success: true, data: { content, truncated: false } };
222
+ } catch (error) {
223
+ return {
224
+ success: false,
225
+ error: `Failed to read "${relPath}": ${error instanceof Error ? error.message : String(error)}`,
226
+ };
227
+ }
228
+ },
229
+ }),
230
+ );
231
+
232
+ // ── write_file ─────────────────────────────────────────────────────────
233
+
234
+ registry.register(
235
+ createToolDefinition({
236
+ name: "write_file",
237
+ description:
238
+ "Write a project file (create or overwrite) at a project-relative path. Jx documents " +
239
+ "(.json under pages/, layouts/, components/) are schema-validated and render-checked " +
240
+ "before writing — fix reported errors and retry. Refused while the target file is open " +
241
+ "with unsaved changes. Disk writes are not undoable.",
242
+ parameters: {
243
+ type: "object",
244
+ properties: {
245
+ path: {
246
+ type: "string",
247
+ description: 'Project-relative file path, e.g. "components/footer.json".',
248
+ },
249
+ content: {
250
+ type: "string",
251
+ description: "The complete new file content (full file, not a diff).",
252
+ },
253
+ },
254
+ required: ["path", "content"],
255
+ },
256
+ async execute(args) {
257
+ const { path, content } = args as { path: string; content: string };
258
+ const relPath = normalizeRelPath(path);
259
+ if (relPath === null) {
260
+ return pathError(path);
261
+ }
262
+ if (content.length > WRITE_CAP_BYTES) {
263
+ return {
264
+ success: false,
265
+ error: `Content is ${content.length} bytes — the write cap is ${WRITE_CAP_BYTES}. Split the content across smaller files.`,
266
+ };
267
+ }
268
+
269
+ // Reconcile with an open tab BEFORE writing: a dirty tab would silently diverge from disk.
270
+ const openTab = findOpenTab(relPath);
271
+ if (openTab?.doc.dirty) {
272
+ return {
273
+ success: false,
274
+ error:
275
+ `"${relPath}" is open in the editor with unsaved changes. Use open_document plus the ` +
276
+ `document tools to edit it, or ask the user to save or discard their changes first.`,
277
+ };
278
+ }
279
+
280
+ // Pre-validate Jx documents — disk writes have no undo to lean on.
281
+ let parsed: unknown;
282
+ const isJson = relPath.endsWith(".json") && relPath !== "project.json";
283
+ if (isJson) {
284
+ try {
285
+ parsed = JSON.parse(content);
286
+ } catch (error) {
287
+ return {
288
+ success: false,
289
+ error: `Content is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
290
+ };
291
+ }
292
+ }
293
+ let tokenHints = "";
294
+ if (isJson && (isJxDocPath(relPath) || looksLikeJxDoc(parsed))) {
295
+ const errors = await validate(parsed);
296
+ if (errors.length > 0) {
297
+ const formatted = errors.map((e) => `- ${translateValidationError(e)}`).join("\n");
298
+ return {
299
+ success: false,
300
+ error: `Document has schema errors — nothing was written. Fix these and retry:\n${formatted}`,
301
+ };
302
+ }
303
+ if (renderCheck) {
304
+ const renderResult = await renderCheck(parsed);
305
+ if (!renderResult.ok) {
306
+ return {
307
+ success: false,
308
+ error: `Document is schema-valid but fails to render — nothing was written. Fix and retry:\n- ${renderResult.error}`,
309
+ };
310
+ }
311
+ }
312
+ const projectStyle = getProjectStyle?.();
313
+ if (projectStyle) {
314
+ tokenHints = formatTokenHints(
315
+ flagHardcodedTokens(parsed as JxMutableNode, projectStyle),
316
+ );
317
+ }
318
+ }
319
+
320
+ if (relPath === "project.json") {
321
+ try {
322
+ JSON.parse(content);
323
+ } catch (error) {
324
+ return {
325
+ success: false,
326
+ error: `project.json must be valid JSON: ${error instanceof Error ? error.message : String(error)}`,
327
+ };
328
+ }
329
+ }
330
+
331
+ try {
332
+ await getPlatform().writeFile(relPath, content);
333
+ } catch (error) {
334
+ return {
335
+ success: false,
336
+ error: `Failed to write "${relPath}": ${error instanceof Error ? error.message : String(error)}`,
337
+ };
338
+ }
339
+
340
+ if (relPath === "project.json") {
341
+ onProjectConfigWritten?.(JSON.parse(content) as object);
342
+ }
343
+
344
+ let summary = `Wrote "${relPath}" ${NOT_UNDOABLE}.`;
345
+ if (openTab) {
346
+ await reloadTab(relPath);
347
+ summary = `Wrote "${relPath}" and refreshed its open editor tab ${NOT_UNDOABLE}.`;
348
+ }
349
+ return { success: true, summary: tokenHints ? `${summary}\n\n${tokenHints}` : summary };
350
+ },
351
+ }),
352
+ );
353
+
354
+ // ── search_files ───────────────────────────────────────────────────────
355
+
356
+ registry.register(
357
+ createToolDefinition({
358
+ name: "search_files",
359
+ description:
360
+ "Search project files by file NAME (not content). Returns matching paths. Use list_files " +
361
+ "for a directory overview and read_file to inspect contents.",
362
+ parameters: {
363
+ type: "object",
364
+ properties: {
365
+ query: { type: "string", description: 'Substring of the file name, e.g. "hero".' },
366
+ extensions: {
367
+ type: "array",
368
+ description: 'Optional extension filter, e.g. [".json", ".md"].',
369
+ items: { type: "string" },
370
+ },
371
+ },
372
+ required: ["query"],
373
+ },
374
+ async execute(args) {
375
+ const { query, extensions } = args as { query: string; extensions?: string[] };
376
+ try {
377
+ const results = await getPlatform().searchFiles(query, extensions);
378
+ const paths = results.map((r) => r.path).slice(0, SEARCH_CAP);
379
+ return {
380
+ success: true,
381
+ data: { paths, truncated: results.length > SEARCH_CAP },
382
+ summary: `Found ${paths.length} file(s) matching "${query}".`,
383
+ };
384
+ } catch (error) {
385
+ return {
386
+ success: false,
387
+ error: `Search failed: ${error instanceof Error ? error.message : String(error)}`,
388
+ };
389
+ }
390
+ },
391
+ }),
392
+ );
393
+
394
+ // ── create_project ─────────────────────────────────────────────────────
395
+
396
+ registry.register(
397
+ createToolDefinition({
398
+ name: "create_project",
399
+ description:
400
+ "Create a new Jx project (project.json, conventional directories, starter pages) and open " +
401
+ "it in the studio. Only available while no project is open. After it succeeds, the file " +
402
+ "and document tools become available for building out the project. If the directory " +
403
+ "already exists, retry with a different directory slug.",
404
+ parameters: {
405
+ type: "object",
406
+ properties: {
407
+ name: { type: "string", description: 'Human project name, e.g. "Acme Bakery".' },
408
+ description: { type: "string", description: "Short project description." },
409
+ template: {
410
+ type: "string",
411
+ description:
412
+ 'Scaffold variant: "blank" (default), "desktop-first", "mobile-first", or "mobile-app".',
413
+ enum: ["blank", "desktop-first", "mobile-first", "mobile-app"],
414
+ },
415
+ directory: {
416
+ type: "string",
417
+ description: "Directory slug to create the project in. Defaults to a slug of the name.",
418
+ },
419
+ design: {
420
+ type: "object",
421
+ description:
422
+ "Optional design quickstart: { accent, background, text, bodyFont, headingFont } — " +
423
+ "CSS colors and font-family names applied to the scaffold's design tokens.",
424
+ },
425
+ },
426
+ required: ["name"],
427
+ },
428
+ async execute(args) {
429
+ const { name, description, template, directory, design } = args as {
430
+ name: string;
431
+ description?: string;
432
+ template?: string;
433
+ directory?: string;
434
+ design?: Record<string, string>;
435
+ };
436
+ if (workspace.projectRoot) {
437
+ return {
438
+ success: false,
439
+ error:
440
+ "A project is already open in this window — create_project is only for bootstrapping.",
441
+ };
442
+ }
443
+ const slug =
444
+ directory?.trim() ||
445
+ name
446
+ .toLowerCase()
447
+ .replaceAll(/[^a-z0-9]+/g, "-")
448
+ .replaceAll(/^-|-$/g, "");
449
+ if (!slug) {
450
+ return { success: false, error: 'Could not derive a directory slug — pass "directory".' };
451
+ }
452
+
453
+ let result: { root: string; config: object };
454
+ try {
455
+ result = await getPlatform().createProject({
456
+ name,
457
+ directory: slug,
458
+ ...(description ? { description } : {}),
459
+ ...(template ? { template } : {}),
460
+ ...(design ? { design } : {}),
461
+ });
462
+ } catch (error) {
463
+ return {
464
+ success: false,
465
+ error: `Failed to create project: ${error instanceof Error ? error.message : String(error)}`,
466
+ };
467
+ }
468
+
469
+ /*
470
+ * Adoption runs the full project-open flow (closes all tabs, opens the home page). The
471
+ * agent loop may hold an undo batch on the pre-adoption tab — flush it first and re-open
472
+ * one on whatever tab adoption leaves active, mirroring open_document's batch dance.
473
+ */
474
+ const wasBatching = isBatching();
475
+ if (wasBatching) {
476
+ endBatch();
477
+ }
478
+ let adoptionError: string | null = null;
479
+ try {
480
+ await adoptProject(result.root);
481
+ } catch (error) {
482
+ adoptionError = error instanceof Error ? error.message : String(error);
483
+ }
484
+ if (wasBatching) {
485
+ beginBatch(getTab());
486
+ }
487
+
488
+ /*
489
+ * The adopter (openRecentProject) swallows failures into a status message, so a resolved
490
+ * promise is not proof of adoption — verify against the workspace before re-keying the
491
+ * chat session.
492
+ */
493
+ const adopted = workspace.projectRoot === result.root;
494
+ if (adopted) {
495
+ onProjectAdopted?.(result.root);
496
+ return {
497
+ success: true,
498
+ summary:
499
+ `Created project "${name}" at ${result.root} and opened it. The file tools ` +
500
+ `(list_files, read_file, write_file) and document tools are now available — start ` +
501
+ `with list_files to see the scaffolded pages, layouts, and components.`,
502
+ };
503
+ }
504
+ return {
505
+ success: true,
506
+ summary:
507
+ `Created project "${name}" at ${result.root}, but it was not opened in this window` +
508
+ `${adoptionError ? ` (${adoptionError})` : " (it may have opened in another window)"}. ` +
509
+ `Ask the user to open it from the welcome screen or recent projects.`,
510
+ };
511
+ },
512
+ }),
513
+ );
514
+
515
+ // ── list_starters ──────────────────────────────────────────────────────
516
+
517
+ registry.register(
518
+ createToolDefinition({
519
+ name: "list_starters",
520
+ description:
521
+ "List the starter templates available for new projects (id, name, description). " +
522
+ "Currently informational — create_project scaffolds from built-in template variants.",
523
+ parameters: { type: "object", properties: {}, required: [] },
524
+ async execute() {
525
+ try {
526
+ const starters = (await getPlatform().listStarters?.()) ?? [];
527
+ return {
528
+ success: true,
529
+ data: { starters },
530
+ summary: `${starters.length} starter(s) available.`,
531
+ };
532
+ } catch (error) {
533
+ return {
534
+ success: false,
535
+ error: `Failed to list starters: ${error instanceof Error ? error.message : String(error)}`,
536
+ };
537
+ }
538
+ },
539
+ }),
540
+ );
541
+ }
@@ -248,3 +248,31 @@ export function deleteSession(root: string, id: string) {
248
248
  }
249
249
  writeIndex(root, index);
250
250
  }
251
+
252
+ /**
253
+ * Move a session between roots — the bootstrap handoff: a chat started with no project (unscoped
254
+ * root "") re-keys to the created project's root so `saveSession` keeps persisting it (it
255
+ * early-returns when the target index has no meta for the id). The moved session becomes the target
256
+ * root's active session. No-op when the session doesn't exist at `fromRoot`; delete-first ordering
257
+ * keeps a re-run idempotent.
258
+ */
259
+ export function moveSession(fromRoot: string, toRoot: string, id: string) {
260
+ if (fromRoot === toRoot) {
261
+ return;
262
+ }
263
+ const msgs = loadSession(fromRoot, id);
264
+ const fromIndex = readIndex(fromRoot);
265
+ const meta = fromIndex.sessions.find((s) => s.id === id);
266
+ if (!msgs || !meta) {
267
+ return;
268
+ }
269
+
270
+ deleteSession(fromRoot, id);
271
+
272
+ writeJson(payloadKey(toRoot, id), msgs);
273
+ const toIndex = readIndex(toRoot);
274
+ toIndex.sessions = toIndex.sessions.filter((s) => s.id !== id);
275
+ toIndex.sessions.unshift({ ...meta });
276
+ toIndex.activeId = id;
277
+ writeIndex(toRoot, toIndex);
278
+ }