create-stitchkit 0.4.2 → 0.4.4

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/CHANGELOG.md CHANGED
@@ -12,6 +12,46 @@ step is overwritten by the next release.
12
12
 
13
13
  ## [Unreleased]
14
14
 
15
+ ## [0.4.4] — 2026-08-30
16
+
17
+ ### Changed
18
+
19
+ - **The generated project now targets the published `stitchkit@0.70.1` line and includes the
20
+ maintained `stitchkit-tui` Agent profile.** The catalog and frozen lockfile move together, so a
21
+ fresh scaffold receives the macOS contained-file fix, bounded realtime shutdown and the terminal
22
+ harness package already validated by the same release train.
23
+
24
+ ### Added
25
+
26
+ - **An explicit Agent template opens a durable OpenRouter coding session in the terminal.**
27
+ `bun create stitchkit my-agent --template agent` generates an OpenTUI host over the published
28
+ headless harness, Bun SQLite storage, lazy skills, direct coding tools, signed approvals and
29
+ recovery. The default production application and its repository overlay are unchanged. The
30
+ Agent development source follows framework HEAD locally, while generated projects receive the
31
+ application starter's one canonical Stitchkit catalog target. Startup requires only an API key:
32
+ a bounded picker reads the current popular tool-capable models and their provider-owned context
33
+ windows directly from OpenRouter. → ADR 0132.
34
+ - **The Agent template now composes the official terminal package instead of copying a product
35
+ shell.** `stitchkit.agent.ts` is the editable typed entrypoint; `/model`, durable sessions,
36
+ approvals, scrolling and local `send`/`interrupt` attachment come from `stitchkit-tui`. The
37
+ generated manifest receives portable catalog dependencies while the repository fixture tests
38
+ both packages from source. → ADR 0133.
39
+
40
+ ## [0.4.3] — 2026-08-28
41
+
42
+ ### Fixed
43
+
44
+ - **Fresh projects use the current published Stitchkit line.** The template's
45
+ single catalog target is `^0.68.6`, and its frozen lockfile resolves exactly
46
+ `0.68.6`. A generated project therefore receives the current contract,
47
+ application and agent-runtime compatibility line instead of remaining below
48
+ `0.61.0` under pre-1.0 caret semantics.
49
+ - **The repository variant carries its optional realtime client into browser
50
+ bundles.** Its `createRealtimeClient` composition supplies a literal
51
+ `socket.io-client` loader while preserving the framework target as the
52
+ template's single source of truth, so a packed scaffold resolves the optional
53
+ peer without adding a second framework range.
54
+
15
55
  ## [0.4.2] — 2026-08-25
16
56
 
17
57
  ### Fixed
package/README.md CHANGED
@@ -13,6 +13,25 @@ The generated Bun workspace contains a Next.js frontend, a separate Stitchkit
13
13
  API, Prisma/PostgreSQL, typed shared contracts, Socket.IO, MCP, CLI tools and a
14
14
  complete production UI system.
15
15
 
16
+ To start from a terminal coding agent instead:
17
+
18
+ ```bash
19
+ bun create stitchkit my-agent --template agent
20
+ cd my-agent
21
+ cp .env.example .env
22
+ # Set OPENROUTER_API_KEY, then choose a live model in the terminal.
23
+ bun run dev
24
+ ```
25
+
26
+ This opt-in profile is deliberately smaller. It composes the published headless
27
+ harness, Bun SQLite store, OpenRouter adapter, lazy filesystem skills and direct
28
+ coding tools behind an OpenTUI shell. Reads/search run directly; writes, edits,
29
+ patches and shell calls require a signed durable `Y`/`N` approval. `bun --watch`
30
+ restarts source while the ignored `.stitchkit/` directory retains conversation
31
+ and recovery state. The bounded startup picker reads current tool-capable models and their context
32
+ windows from OpenRouter instead of duplicating provider metadata in environment variables. The
33
+ workspace path is a containment boundary, not an OS sandbox.
34
+
16
35
  It uses one conventional `packages/*` namespace: `backend`, `frontend`,
17
36
  `config`, `db` and `shared`. The destination name becomes the generated slug;
18
37
  `--display-name` sets the human title. Both are recorded once in
package/dist/cli.js CHANGED
@@ -207,10 +207,11 @@ function withIdentity(declaration, identity) {
207
207
  var HELP = `Create a production-shaped Stitchkit application.
208
208
 
209
209
  Usage:
210
- bun create stitchkit <directory> [--display-name "Product Name"] [--example repository] [--no-install]
210
+ bun create stitchkit <directory> [--template application|agent] [--display-name "Product Name"] [--example repository] [--no-install]
211
211
 
212
212
  Options:
213
213
  --no-install Generate files without installing dependencies
214
+ --template Select the project shape (default: application)
214
215
  --example Add an isolated runnable example (supported: repository)
215
216
  --display-name Set the initial public application name
216
217
  --help Show this help
@@ -221,7 +222,7 @@ function helpText() {
221
222
  function parseOptions(args) {
222
223
  if (args.includes("--help") || args.includes("-h"))
223
224
  return "help";
224
- const unknown = args.filter((arg) => arg.startsWith("-") && arg !== "--no-install" && arg !== "--example" && arg !== "--display-name");
225
+ const unknown = args.filter((arg) => arg.startsWith("-") && arg !== "--no-install" && arg !== "--template" && arg !== "--example" && arg !== "--display-name");
225
226
  if (unknown.length > 0) {
226
227
  throw new Error(`Unknown option: ${unknown[0]}`);
227
228
  }
@@ -233,14 +234,26 @@ function parseOptions(args) {
233
234
  if (example !== undefined && example !== "repository") {
234
235
  throw new Error(`Unknown example: ${example}`);
235
236
  }
237
+ const templateFlagIndex = args.indexOf("--template");
238
+ const template = templateFlagIndex === -1 ? "application" : args[templateFlagIndex + 1];
239
+ if (templateFlagIndex !== -1 && template === undefined) {
240
+ throw new Error("--template requires a value");
241
+ }
242
+ if (template !== "application" && template !== "agent") {
243
+ throw new Error(`Unknown template: ${template}`);
244
+ }
245
+ if (template === "agent" && example !== undefined) {
246
+ throw new Error("--example is only supported by the application template");
247
+ }
236
248
  const displayNameFlagIndex = args.indexOf("--display-name");
237
249
  const displayName = displayNameFlagIndex === -1 ? undefined : args[displayNameFlagIndex + 1];
238
250
  if (displayNameFlagIndex !== -1 && displayName === undefined) {
239
251
  throw new Error("--display-name requires a value");
240
252
  }
241
253
  const consumedExampleIndex = exampleFlagIndex === -1 ? -1 : exampleFlagIndex + 1;
254
+ const consumedTemplateIndex = templateFlagIndex === -1 ? -1 : templateFlagIndex + 1;
242
255
  const consumedDisplayNameIndex = displayNameFlagIndex === -1 ? -1 : displayNameFlagIndex + 1;
243
- const positionals = args.filter((arg, index) => !arg.startsWith("-") && index !== consumedExampleIndex && index !== consumedDisplayNameIndex);
256
+ const positionals = args.filter((arg, index) => !arg.startsWith("-") && index !== consumedExampleIndex && index !== consumedTemplateIndex && index !== consumedDisplayNameIndex);
244
257
  if (positionals.length !== 1) {
245
258
  throw new Error("Exactly one destination directory is required");
246
259
  }
@@ -250,6 +263,7 @@ function parseOptions(args) {
250
263
  return {
251
264
  destination,
252
265
  install: !args.includes("--no-install"),
266
+ template,
253
267
  ...example && { example },
254
268
  ...displayName && { displayName }
255
269
  };
@@ -283,9 +297,15 @@ var TEMPLATE_RENAMES = new Map([
283
297
  ["_env.example.append", ".env.example"],
284
298
  ["_gitignore", ".gitignore"]
285
299
  ]);
286
- var RootManifestSchema = z2.looseObject({ name: z2.string().min(1) });
300
+ var RootManifestSchema = z2.looseObject({
301
+ name: z2.string().min(1),
302
+ catalog: z2.record(z2.string(), z2.string()).optional(),
303
+ dependencies: z2.record(z2.string(), z2.string()).optional(),
304
+ devDependencies: z2.record(z2.string(), z2.string()).optional()
305
+ });
287
306
  var IGNORED_DIRECTORIES = new Set([
288
307
  ".next",
308
+ ".stitchkit",
289
309
  "coverage",
290
310
  "dist",
291
311
  "node_modules",
@@ -401,16 +421,43 @@ async function scaffoldProject(templateDirectory, destination, options = {}) {
401
421
  if (options.overlayDirectory) {
402
422
  await writeMaterialisedFiles(resolvedDestination, await materialiseTemplateFiles(options.overlayDirectory));
403
423
  }
424
+ if (options.lockfile === false) {
425
+ await rm(join(resolvedDestination, "bun.lock"), { force: true });
426
+ }
404
427
  const declarationPath = join(resolvedDestination, "project.json");
405
428
  const declaration = withIdentity(JSON.parse(await readFile(declarationPath, "utf8")), identity);
406
429
  await writeFile(declarationPath, `${JSON.stringify(declaration, undefined, 2)}
407
430
  `);
408
- const identityPath = join(resolvedDestination, APP_IDENTITY_PATH);
409
- await mkdir(dirname(identityPath), { recursive: true });
410
- await writeFile(identityPath, renderAppIdentityModule(declaration.identity));
431
+ if (options.identityModule !== false) {
432
+ const identityPath = join(resolvedDestination, APP_IDENTITY_PATH);
433
+ await mkdir(dirname(identityPath), { recursive: true });
434
+ await writeFile(identityPath, renderAppIdentityModule(declaration.identity));
435
+ }
411
436
  const manifestPath = join(resolvedDestination, "package.json");
412
437
  const manifest = RootManifestSchema.parse(JSON.parse(await readFile(manifestPath, "utf8")));
413
- await writeFile(manifestPath, `${JSON.stringify({ ...manifest, name: identity.slug }, undefined, 2)}
438
+ const catalog = options.stitchkitCatalogTarget ? { ...manifest.catalog ?? {}, stitchkit: options.stitchkitCatalogTarget } : manifest.catalog;
439
+ const replaceLocalPackages = (dependencies) => {
440
+ if (!dependencies)
441
+ return dependencies;
442
+ return {
443
+ ...dependencies,
444
+ ...dependencies.stitchkit?.startsWith("file:") && { stitchkit: "catalog:" },
445
+ ...dependencies["stitchkit-tui"]?.startsWith("file:") && {
446
+ "stitchkit-tui": "catalog:"
447
+ }
448
+ };
449
+ };
450
+ await writeFile(manifestPath, `${JSON.stringify({
451
+ ...manifest,
452
+ name: identity.slug,
453
+ ...catalog && { catalog },
454
+ ...manifest.dependencies && {
455
+ dependencies: replaceLocalPackages(manifest.dependencies)
456
+ },
457
+ ...manifest.devDependencies && {
458
+ devDependencies: replaceLocalPackages(manifest.devDependencies)
459
+ }
460
+ }, undefined, 2)}
414
461
  `);
415
462
  } catch (error) {
416
463
  if (!destinationExisted) {
@@ -432,11 +479,17 @@ async function run(args) {
432
479
  return 0;
433
480
  }
434
481
  const destination = resolve2(options.destination);
435
- const templateDirectory = resolve2(import.meta.dir, "../template");
482
+ const applicationTemplateDirectory = resolve2(import.meta.dir, "../template");
483
+ const templateDirectory = options.template === "agent" ? resolve2(import.meta.dir, "../templates/agent") : applicationTemplateDirectory;
436
484
  const overlayDirectory = options.example ? resolve2(import.meta.dir, `../examples/${options.example}`) : undefined;
437
485
  await scaffoldProject(templateDirectory, destination, {
438
486
  ...overlayDirectory && { overlayDirectory },
439
- ...options.displayName && { displayName: options.displayName }
487
+ ...options.displayName && { displayName: options.displayName },
488
+ ...options.template === "agent" && {
489
+ identityModule: false,
490
+ lockfile: false,
491
+ stitchkitCatalogTarget: await readStitchkitCatalogTarget(applicationTemplateDirectory)
492
+ }
440
493
  });
441
494
  if (options.install) {
442
495
  const install = spawn(["bun", "install"], {
@@ -449,7 +502,7 @@ async function run(args) {
449
502
  if (exitCode !== 0)
450
503
  throw new Error(`bun install failed with exit code ${exitCode}`);
451
504
  }
452
- const mode = options.example ? ` with the ${options.example} example` : "";
505
+ const mode = options.template === "agent" ? " from the agent template" : options.example ? ` with the ${options.example} example` : "";
453
506
  process.stdout.write(`
454
507
  Created ${options.displayName ?? basename3(destination)}${mode}
455
508
 
@@ -471,6 +524,13 @@ Created ${options.displayName ?? basename3(destination)}${mode}
471
524
  return 1;
472
525
  }
473
526
  }
527
+ async function readStitchkitCatalogTarget(templateDirectory) {
528
+ const manifest = JSON.parse(await readFile2(join2(templateDirectory, "package.json"), "utf8"));
529
+ if (typeof manifest !== "object" || manifest === null || !("catalog" in manifest) || typeof manifest.catalog !== "object" || manifest.catalog === null || !("stitchkit" in manifest.catalog) || typeof manifest.catalog.stitchkit !== "string") {
530
+ throw new Error("Application template is missing catalog.stitchkit");
531
+ }
532
+ return manifest.catalog.stitchkit;
533
+ }
474
534
  if (import.meta.main) {
475
535
  process.exitCode = await run(Bun.argv.slice(2));
476
536
  }
@@ -20,7 +20,14 @@ function buildSocket() {
20
20
  // browser actually dialled, read at connect time, exactly like the server
21
21
  // reads the request's origin. This function only ever runs in an effect.
22
22
  const url = optionalRealtimeOrigin() ?? window.location.origin;
23
- return createRealtimeClient(repositoryRealtimeContract, { url });
23
+ // Keep this recipe source-compatible with the template's current published
24
+ // Stitchkit target: structural typing lets the older additive API ignore
25
+ // `peers`, while current Stitchkit consumes the literal loader.
26
+ const options = {
27
+ url,
28
+ peers: { client: () => import('socket.io-client') },
29
+ };
30
+ return createRealtimeClient(repositoryRealtimeContract, options);
24
31
  }
25
32
 
26
33
  function buildBridge() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-stitchkit",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "description": "Create a production-shaped Stitchkit application",
5
5
  "license": "MIT",
6
6
  "author": "Max Listov <maxlistov@gmail.com>",
@@ -16,11 +16,13 @@
16
16
  "files": [
17
17
  "dist",
18
18
  "template/**/*",
19
+ "templates/**/*",
19
20
  "examples/**/*",
20
21
  "!template/**/.env",
21
22
  "!template/**/.build-stamp.json",
22
23
  "!template/**/node_modules/**",
23
24
  "!template/**/.next/**",
25
+ "!template/**/.stitchkit/**",
24
26
  "!template/**/dist/**",
25
27
  "!template/**/playwright-report/**",
26
28
  "!template/**/test-results/**",
@@ -29,10 +31,25 @@
29
31
  "!template/**/*.log",
30
32
  "!template/**/*.tsbuildinfo",
31
33
  "!template/**/coverage/**",
34
+ "!templates/**/.env",
35
+ "!templates/**/.build-stamp.json",
36
+ "!templates/**/node_modules/**",
37
+ "!templates/**/.next/**",
38
+ "!templates/**/.stitchkit/**",
39
+ "!templates/**/dist/**",
40
+ "!templates/**/playwright-report/**",
41
+ "!templates/**/test-results/**",
42
+ "!templates/**/next-env.d.ts",
43
+ "!templates/**/src/generated/**",
44
+ "!templates/**/*.log",
45
+ "!templates/**/*.tsbuildinfo",
46
+ "!templates/**/coverage/**",
47
+ "!templates/agent/bun.lock",
32
48
  "!examples/**/.env",
33
49
  "!examples/**/.build-stamp.json",
34
50
  "!examples/**/node_modules/**",
35
51
  "!examples/**/.next/**",
52
+ "!examples/**/.stitchkit/**",
36
53
  "!examples/**/dist/**",
37
54
  "!examples/**/coverage/**",
38
55
  "!examples/**/playwright-report/**",
@@ -59,7 +76,14 @@
59
76
  "zod": "^4.4.3"
60
77
  },
61
78
  "devDependencies": {
79
+ "@opentui/core": "^0.5.9",
80
+ "@opentui/react": "^0.5.9",
81
+ "@openrouter/ai-sdk-provider": "^3.0.0",
62
82
  "@types/bun": "^1.4.0",
83
+ "@types/react": "^19.2.18",
84
+ "ai": "^7.0.84",
85
+ "react": "^19.2.8",
86
+ "stitchkit": "0.70.1",
63
87
  "typescript": "^7.0.2"
64
88
  },
65
89
  "engines": {
package/template/bun.lock CHANGED
@@ -139,7 +139,7 @@
139
139
  },
140
140
  },
141
141
  "catalog": {
142
- "stitchkit": "^0.60.1",
142
+ "stitchkit": "^0.70.1",
143
143
  },
144
144
  "packages": {
145
145
  "@ai-sdk/gateway": ["@ai-sdk/gateway@4.0.63", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@ai-sdk/provider-utils": "5.0.29", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-D7BogSRg61QfTdr7AEcYn9h0I/e4QHvFXwIV1RW+DZZGJu1wSiX2cH06szZSYyKi7Eat50V4s4J8vggZUEs7eg=="],
@@ -1118,7 +1118,7 @@
1118
1118
 
1119
1119
  "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
1120
1120
 
1121
- "stitchkit": ["stitchkit@0.60.1", "", { "dependencies": { "ky": "^2.0.2" }, "peerDependencies": { "@modelcontextprotocol/ext-apps": "^1.7.2", "@modelcontextprotocol/server": "^2.0.0", "@openrouter/ai-sdk-provider": "^3.0.0", "@opentelemetry/api": "^1.9.0", "@socket.io/bun-engine": "^0.1.1", "@socket.io/component-emitter": "^3.1.2", "@tanstack/react-query": ">=5", "@types/bun": "^1.3.14", "ai": "^7.0.0", "grammy": "^1.45.1", "react": ">=18", "react-query-kit": "^3.3.3", "socket.io": "^4.8.3", "socket.io-client": "^4.8.3", "srvx": "^0.12.5", "zod": "^4.4.3" }, "optionalPeers": ["@modelcontextprotocol/ext-apps", "@modelcontextprotocol/server", "@openrouter/ai-sdk-provider", "@opentelemetry/api", "@socket.io/bun-engine", "@socket.io/component-emitter", "@tanstack/react-query", "@types/bun", "ai", "grammy", "react", "react-query-kit", "socket.io", "socket.io-client", "srvx"] }, "sha512-+ZXp+TaKuwBkOCT/8wpHFtRnaeoVIrzAnPFDGAVfjpN1tdP06Ve83cKV7aciMHHWrXmmy9EvenR004buhqn+Qg=="],
1121
+ "stitchkit": ["stitchkit@0.70.1", "", { "dependencies": { "ky": "^2.0.2" }, "peerDependencies": { "@modelcontextprotocol/ext-apps": "^1.7.2", "@modelcontextprotocol/server": "^2.0.0", "@openrouter/ai-sdk-provider": "^3.0.0", "@opentelemetry/api": "^1.9.0", "@socket.io/bun-engine": "^0.1.1", "@socket.io/component-emitter": "^3.1.2", "@tanstack/react-query": ">=5", "@types/bun": "^1.3.14", "ai": "^7.0.0", "grammy": "^1.45.1", "react": ">=18", "react-query-kit": "^3.3.3", "socket.io": "^4.8.3", "socket.io-client": "^4.8.3", "srvx": "^0.12.5", "zod": "^4.4.3" }, "optionalPeers": ["@modelcontextprotocol/ext-apps", "@modelcontextprotocol/server", "@openrouter/ai-sdk-provider", "@opentelemetry/api", "@socket.io/bun-engine", "@socket.io/component-emitter", "@tanstack/react-query", "@types/bun", "ai", "grammy", "react", "react-query-kit", "socket.io", "socket.io-client", "srvx"] }, "sha512-UySE/DO1p7XZDmbISX3+U9RCYpepqsElovnL4IgUu0C9BpsFXGbYpoF0nL38vpY8SLV2frLiZXyy05gmmyrhrg=="],
1122
1122
 
1123
1123
  "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
1124
1124
 
@@ -7,7 +7,7 @@
7
7
  "packages/*"
8
8
  ],
9
9
  "catalog": {
10
- "stitchkit": "^0.60.1"
10
+ "stitchkit": "^0.70.1"
11
11
  },
12
12
  "scripts": {
13
13
  "dev": "bun scripts/dev.ts",
@@ -30,4 +30,19 @@ describe('ensureLocalEnvironment', () => {
30
30
  await rm(root, { recursive: true, force: true });
31
31
  }
32
32
  });
33
+
34
+ test('a clean framework source tree reads the pre-scaffold example name', async () => {
35
+ const root = await mkdtemp(join(tmpdir(), 'sk-source-env-'));
36
+ try {
37
+ await writeFile(
38
+ join(root, '_env.example'),
39
+ 'DATABASE_URL=postgresql://USER:PASSWORD@127.0.0.1:5432/stitchkit_starter\n',
40
+ );
41
+ ensureLocalEnvironment(root);
42
+ const databaseName = appDeclaration.identity.slug.replaceAll('-', '_');
43
+ expect(await readFile(join(root, '.env'), 'utf8')).toContain(`5432/${databaseName}`);
44
+ } finally {
45
+ await rm(root, { recursive: true, force: true });
46
+ }
47
+ });
33
48
  });
@@ -3,8 +3,11 @@ import { resolve } from 'node:path';
3
3
  import { appIdentity } from '../packages/config/src/app-identity.generated';
4
4
 
5
5
  /**
6
- * Create `.env` from `.env.example` on first run, rendering the application
7
- * identity into the database name.
6
+ * Create `.env` from the public `.env.example` on first run, rendering the
7
+ * application identity into the database name. The framework repository keeps
8
+ * that same source as `_env.example` until the scaffolder performs its rename,
9
+ * so clean source-tree checks use it directly instead of depending on an
10
+ * ignored developer `.env`.
8
11
  *
9
12
  * Identity, not the whole declaration: this needs one slug, and the identity
10
13
  * module carries no dependencies. That matters here more than elsewhere —
@@ -19,7 +22,11 @@ import { appIdentity } from '../packages/config/src/app-identity.generated';
19
22
  export function ensureLocalEnvironment(root: string): void {
20
23
  const destination = resolve(root, '.env');
21
24
  if (existsSync(destination)) return;
22
- const example = readFileSync(resolve(root, '.env.example'), 'utf8');
25
+ const publicExample = resolve(root, '.env.example');
26
+ const example = readFileSync(
27
+ existsSync(publicExample) ? publicExample : resolve(root, '_env.example'),
28
+ 'utf8',
29
+ );
23
30
  const databaseName = appIdentity.slug.replaceAll('-', '_');
24
31
  writeFileSync(destination, example.replaceAll('stitchkit_starter', databaseName));
25
32
  }
@@ -43,16 +43,23 @@ describe('the termination budget is an upper bound, not an estimate', () => {
43
43
  });
44
44
 
45
45
  test('the steps share one budget rather than each getting a full one', async () => {
46
+ const started: string[] = [];
47
+ const hangingClose = (name: string) => () => {
48
+ started.push(name);
49
+ return new Promise<void>(() => undefined);
50
+ };
51
+ const names = Array.from({ length: 10 }, (_, index) => `resource-${index + 1}`);
46
52
  const result = await closeWithinBudget(
47
- [
48
- { name: 'MCP', close: () => new Promise<void>(() => undefined) },
49
- { name: 'database', close: () => new Promise<void>(() => undefined) },
50
- ],
53
+ names.map((name) => ({ name, close: hangingClose(name) })),
51
54
  25,
52
55
  );
53
- expect(result.unfinished).toEqual(['MCP', 'database']);
54
- // Two steps, one budget: about one budget of wall clock, not two.
55
- expect(result.durationMs).toBeLessThan(50);
56
+ expect(result.unfinished).toEqual(names);
57
+ // Timer rounding may let one close start on a sub-millisecond remainder,
58
+ // so no exact boundary is contractual. What a fresh per-step budget would
59
+ // do is start every close; one shared deadline must skip at least one. This
60
+ // proves the state transition directly without treating scheduler latency
61
+ // as a product failure.
62
+ expect(started.length).toBeLessThan(names.length);
56
63
  });
57
64
 
58
65
  test('a close that fails is a failure, and keeps its cause', async () => {
@@ -0,0 +1,41 @@
1
+ # Stitchkit Agent
2
+
3
+ A small, real terminal coding agent. The official TUI package owns terminal interaction while
4
+ Stitchkit owns durable messages, runs, direct typed tools, approvals, recovery and resources.
5
+
6
+ ## Start
7
+
8
+ ```bash
9
+ cp .env.example .env
10
+ # Fill OPENROUTER_API_KEY.
11
+ bun run dev
12
+ ```
13
+
14
+ Use `/model` to choose any live tool-capable model. Weekly popularity and benchmark facts stay
15
+ separate in the catalog. File reads and searches run directly. Writes, patches and shell commands
16
+ show an approval card bound to the exact durable tool call; press `Y` or `N`.
17
+
18
+ Source edits restart the terminal host through `bun --watch`, while `.stitchkit/agent.sqlite`
19
+ retains durable conversations and recovery evidence. Every launch opens a fresh conversation;
20
+ use `/resume` to return to an earlier one. `/clear` starts clean without deleting the conversation
21
+ you are leaving.
22
+
23
+ ## Shape
24
+
25
+ - `stitchkit.agent.ts` — the small, typed composition point for theme, catalog and runtime policy.
26
+ - `src/runtime.ts` — host policy and composition of published Stitchkit primitives.
27
+ - `stitchkit-tui` — commands, transcript, model/session pickers and local attach protocol.
28
+ - `instructions/` — eager instructions with explicit provenance.
29
+ - `skills/*/SKILL.md` — lazily discoverable skills read through the direct `read_resource` tool.
30
+ - `.stitchkit/` — ignored local durable state, session descriptors, bounded metadata diagnostics
31
+ and approval secret.
32
+
33
+ While the TUI is open, `/status` shows its session ID. Another local process can submit through
34
+ the same controller without racing the runtime:
35
+
36
+ ```bash
37
+ bunx stitchkit-agent send --session SESSION_ID -- "Inspect the current project"
38
+ ```
39
+
40
+ The coding root is a path boundary, not an operating-system sandbox. Run the process in a container
41
+ or another isolated environment before granting it access to untrusted projects or executables.
@@ -0,0 +1,4 @@
1
+ # Create an API key at https://openrouter.ai/settings/keys
2
+ OPENROUTER_API_KEY=
3
+ # Optional: preselect this exact row when the live model picker opens.
4
+ OPENROUTER_MODEL=
@@ -0,0 +1,6 @@
1
+ node_modules/
2
+ dist/
3
+ .env
4
+ .stitchkit/
5
+ coverage/
6
+ *.log
@@ -0,0 +1,30 @@
1
+ {
2
+ "$schema": "https://biomejs.dev/schemas/2.5.10/schema.json",
3
+ "files": {
4
+ "includes": ["**", "!!node_modules", "!!dist", "!!project.json", "!!**/.stitchkit"]
5
+ },
6
+ "formatter": {
7
+ "enabled": true,
8
+ "indentStyle": "space",
9
+ "indentWidth": 2,
10
+ "lineWidth": 96
11
+ },
12
+ "javascript": {
13
+ "formatter": {
14
+ "quoteStyle": "single",
15
+ "jsxQuoteStyle": "single",
16
+ "trailingCommas": "all",
17
+ "semicolons": "always"
18
+ }
19
+ },
20
+ "linter": {
21
+ "enabled": true,
22
+ "rules": {
23
+ "preset": "recommended",
24
+ "suspicious": {
25
+ "noExplicitAny": "error",
26
+ "noUnknownAttribute": "off"
27
+ }
28
+ }
29
+ }
30
+ }
@@ -0,0 +1,9 @@
1
+ # Agent workspace
2
+
3
+ You are a coding agent working inside this generated project.
4
+
5
+ - Inspect existing files before changing them.
6
+ - Keep edits inside the workspace root.
7
+ - Use direct file and shell tools; never claim a command succeeded without reading its result.
8
+ - Ask for approval through the tool protocol when a write, patch, edit or shell command requires it.
9
+ - Prefer the smallest coherent implementation and leave the workspace in a checked state.
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "stitchkit-agent-starter",
3
+ "private": true,
4
+ "version": "0.1.0",
5
+ "type": "module",
6
+ "catalog": {
7
+ "stitchkit-tui": "^0.1.1"
8
+ },
9
+ "scripts": {
10
+ "dev": "bun --watch src/index.ts",
11
+ "start": "bun src/index.ts",
12
+ "check": "bun x tsgo --noEmit",
13
+ "lint": "bun x biome check --error-on-warnings .",
14
+ "lint:fix": "bun x biome check --write .",
15
+ "test": "bun test",
16
+ "build": "bun build src/index.ts --outdir dist --target bun --packages external"
17
+ },
18
+ "dependencies": {
19
+ "stitchkit-tui": "file:../../../tui",
20
+ "@openrouter/ai-sdk-provider": "^3.0.0",
21
+ "ai": "^7.0.84",
22
+ "stitchkit": "file:../../../core",
23
+ "zod": "4.4.3"
24
+ },
25
+ "devDependencies": {
26
+ "@typescript/native-preview": "7.0.0-dev.20260707.2",
27
+ "@types/bun": "^1.4.0",
28
+ "@types/react": "^19.2.14",
29
+ "typescript": "^7.0.2"
30
+ },
31
+ "engines": {
32
+ "bun": ">=1.3.0"
33
+ },
34
+ "packageManager": "bun@1.3.14"
35
+ }
@@ -0,0 +1,45 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "kind": "application",
4
+ "identity": {
5
+ "slug": "stitchkit-agent-starter",
6
+ "name": "Stitchkit Agent Starter",
7
+ "version": "0.1.0",
8
+ "description": {
9
+ "en": "A durable terminal coding agent built with Stitchkit."
10
+ }
11
+ },
12
+ "roles": [
13
+ {
14
+ "name": "agent",
15
+ "workingDirectory": ".",
16
+ "commands": {
17
+ "development": {
18
+ "executable": "bun",
19
+ "args": ["--watch", "src/index.ts"]
20
+ },
21
+ "production": {
22
+ "executable": "bun",
23
+ "args": ["dist/index.js"]
24
+ }
25
+ },
26
+ "drainFloorMs": 1000
27
+ }
28
+ ],
29
+ "requires": [],
30
+ "release": {},
31
+ "env": {
32
+ "variables": [
33
+ {
34
+ "name": "OPENROUTER_API_KEY",
35
+ "shape": "string",
36
+ "required": true
37
+ },
38
+ {
39
+ "name": "OPENROUTER_MODEL",
40
+ "shape": "string",
41
+ "required": false
42
+ }
43
+ ]
44
+ }
45
+ }
@@ -0,0 +1,10 @@
1
+ ---
2
+ name: verify
3
+ description: Check a completed change with the project's own Bun scripts before reporting success.
4
+ ---
5
+
6
+ # Verify
7
+
8
+ Read `package.json`, choose the narrowest relevant script, run it with the direct shell tool and
9
+ report the exact result. Use `bun run check` and `bun test` when the change affects shared types or
10
+ behavior. Do not invent a green result from code inspection alone.
@@ -0,0 +1,32 @@
1
+ import { z } from 'zod';
2
+
3
+ const EnvironmentSchema = z
4
+ .object({
5
+ OPENROUTER_API_KEY: z.string().min(1, 'OPENROUTER_API_KEY is required'),
6
+ OPENROUTER_MODEL: z.string().min(1).optional(),
7
+ })
8
+ .loose();
9
+
10
+ export interface AgentConfig {
11
+ apiKey: string;
12
+ preferredModelId?: string;
13
+ }
14
+
15
+ export function readAgentConfig(environment: Record<string, string | undefined>): AgentConfig {
16
+ const result = EnvironmentSchema.safeParse(environment);
17
+ if (!result.success) {
18
+ const fields = [
19
+ ...new Set(
20
+ result.error.issues
21
+ .map((issue) => issue.path[0])
22
+ .filter((field) => field !== undefined),
23
+ ),
24
+ ];
25
+ throw new Error(`Missing or invalid configuration: ${fields.join(', ')}`);
26
+ }
27
+ const parsed = result.data;
28
+ return {
29
+ apiKey: parsed.OPENROUTER_API_KEY,
30
+ ...(parsed.OPENROUTER_MODEL && { preferredModelId: parsed.OPENROUTER_MODEL }),
31
+ };
32
+ }
@@ -0,0 +1,4 @@
1
+ import { runAgentTui } from 'stitchkit-tui';
2
+ import config from '../stitchkit.agent';
3
+
4
+ await runAgentTui(config);
@@ -0,0 +1,139 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import {
4
+ type AgentLanguageModelProvider,
5
+ type AgentModelCatalog,
6
+ type AgentModelSelectionStore,
7
+ createAgentObservability,
8
+ defineAgentProtocol,
9
+ } from 'stitchkit/agent-runtime';
10
+ import { createAgentCodingTools } from 'stitchkit/agent-runtime/coding-tools';
11
+ import {
12
+ createAgentHarnessFileResources,
13
+ createHeadlessAgentHarness,
14
+ } from 'stitchkit/agent-runtime/harness';
15
+ import { openRouterProvider } from 'stitchkit/agent-runtime/openrouter';
16
+ import { createBunSqliteAgentRuntimeStore } from 'stitchkit/agent-runtime/sqlite/bun';
17
+ import { mountAgent } from 'stitchkit/tools';
18
+ import type { AgentTuiDiagnostics } from 'stitchkit-tui';
19
+ import { z } from 'zod';
20
+ import type { AgentConfig } from './config';
21
+
22
+ const InputMetadataSchema = z.object({ modelId: z.string().min(1) }).strict();
23
+
24
+ async function persistentApprovalSecret(stateDirectory: string): Promise<string> {
25
+ const filename = path.join(stateDirectory, 'approval-secret');
26
+ await mkdir(stateDirectory, { recursive: true });
27
+ try {
28
+ return (await readFile(filename, 'utf8')).trim();
29
+ } catch (error) {
30
+ if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) throw error;
31
+ }
32
+ const secret = crypto.randomUUID();
33
+ try {
34
+ await writeFile(filename, `${secret}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
35
+ return secret;
36
+ } catch (error) {
37
+ if (!(error instanceof Error && 'code' in error && error.code === 'EEXIST')) throw error;
38
+ return (await readFile(filename, 'utf8')).trim();
39
+ }
40
+ }
41
+
42
+ export async function createStarterHarness(
43
+ config: AgentConfig,
44
+ workspace: string,
45
+ options: {
46
+ catalog: AgentModelCatalog;
47
+ selections: AgentModelSelectionStore;
48
+ provider?: AgentLanguageModelProvider;
49
+ diagnostics: AgentTuiDiagnostics;
50
+ },
51
+ ) {
52
+ const stateDirectory = path.join(workspace, '.stitchkit');
53
+ await mkdir(stateDirectory, { recursive: true });
54
+ const resources = createAgentHarnessFileResources({
55
+ roots: [
56
+ { id: 'instructions', path: path.join(workspace, 'instructions'), kind: 'instruction' },
57
+ { id: 'skills', path: path.join(workspace, 'skills'), kind: 'skill' },
58
+ ],
59
+ });
60
+ const codingTools = createAgentCodingTools({
61
+ root: workspace,
62
+ authorize: () => true,
63
+ executables: {
64
+ bun: process.execPath,
65
+ git: Bun.which('git') ?? '/usr/bin/git',
66
+ rg: Bun.which('rg') ?? '/usr/bin/rg',
67
+ },
68
+ });
69
+ const provider = options.provider ?? openRouterProvider({ apiKey: config.apiKey });
70
+ const sqlite = createBunSqliteAgentRuntimeStore({
71
+ filename: path.join(stateDirectory, 'agent.sqlite'),
72
+ initialize: true,
73
+ });
74
+ const observability = createAgentObservability({
75
+ includeInternalCause: true,
76
+ write: (event) => options.diagnostics.write(event),
77
+ });
78
+ const harness = createHeadlessAgentHarness({
79
+ protocol: defineAgentProtocol({
80
+ context: z.object({}),
81
+ inputMetadata: InputMetadataSchema,
82
+ terminalAcceptance: 'require-output',
83
+ }),
84
+ store: sqlite.store,
85
+ observe: observability,
86
+ models: {
87
+ async resolve({ conversationId, run, snapshot }) {
88
+ const input = snapshot.messages.find(({ id }) => id === run.inputMessageIds[0]);
89
+ const metadata = InputMetadataSchema.safeParse(input?.metadata);
90
+ const selected = metadata.success
91
+ ? metadata.data.modelId
92
+ : (await options.selections.load(conversationId))?.modelId;
93
+ const entry = options.catalog.models.find(({ id }) => id === selected);
94
+ if (!entry) throw new Error('The selected model is stale or unavailable');
95
+ return {
96
+ descriptor: entry.descriptor,
97
+ model: provider.create(entry.descriptor.modelId),
98
+ ...(provider.normalizeUsage && { normalizeUsage: provider.normalizeUsage }),
99
+ };
100
+ },
101
+ },
102
+ resources: { load: () => resources.load() },
103
+ promptBudget: ({ contextWindow }) => ({
104
+ contextWindow,
105
+ reservedOutput: Math.min(8_192, Math.floor(contextWindow / 4)),
106
+ toolSchemas: { provenance: 'unavailable' },
107
+ attachments: { value: 0, provenance: 'measured' },
108
+ providerOverhead: { provenance: 'unavailable' },
109
+ }),
110
+ tools: (context) =>
111
+ mountAgent([], {
112
+ runtimeTools: [...codingTools, ...resources.runtimeTools],
113
+ lifecycle: context.toolFenceLifecycle,
114
+ }),
115
+ loop: {
116
+ maxSteps: 50,
117
+ checkpointEveryEvents: 10,
118
+ toolApproval: {
119
+ read_file: 'approved',
120
+ search_files: 'approved',
121
+ read_resource: 'approved',
122
+ write_file: 'user-approval',
123
+ apply_patch: 'user-approval',
124
+ run_command: 'user-approval',
125
+ },
126
+ toolApprovalSecret: await persistentApprovalSecret(stateDirectory),
127
+ },
128
+ });
129
+ const managedHarness = {
130
+ ...harness,
131
+ async close(closeOptions?: Parameters<typeof harness.close>[0]) {
132
+ const result = await harness.close(closeOptions);
133
+ await observability.close();
134
+ await sqlite.close();
135
+ return result;
136
+ },
137
+ };
138
+ return { harness: managedHarness, conversations: sqlite.conversations };
139
+ }
@@ -0,0 +1,15 @@
1
+ import { openRouterModelCatalog } from 'stitchkit/agent-runtime/openrouter';
2
+ import { defineAgentTui } from 'stitchkit-tui';
3
+ import { readAgentConfig } from './src/config';
4
+ import { createStarterHarness } from './src/runtime';
5
+
6
+ const environment = readAgentConfig(Bun.env);
7
+
8
+ export default defineAgentTui({
9
+ title: 'Stitchkit agent',
10
+ context: () => ({}),
11
+ modelCatalog: openRouterModelCatalog({ apiKey: environment.apiKey }),
12
+ ...(environment.preferredModelId && { preferredModelId: environment.preferredModelId }),
13
+ createRuntime: ({ catalog, selections, diagnostics }) =>
14
+ createStarterHarness(environment, process.cwd(), { catalog, selections, diagnostics }),
15
+ });
@@ -0,0 +1,17 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { readAgentConfig } from '../src/config';
3
+
4
+ describe('agent config', () => {
5
+ test('requires only an OpenRouter credential and accepts an optional preferred model', () => {
6
+ expect(() => readAgentConfig({})).toThrow(
7
+ 'Missing or invalid configuration: OPENROUTER_API_KEY',
8
+ );
9
+ expect(readAgentConfig({ OPENROUTER_API_KEY: 'secret' })).toEqual({ apiKey: 'secret' });
10
+ expect(
11
+ readAgentConfig({
12
+ OPENROUTER_API_KEY: 'secret',
13
+ OPENROUTER_MODEL: 'provider/model',
14
+ }),
15
+ ).toEqual({ apiKey: 'secret', preferredModelId: 'provider/model' });
16
+ });
17
+ });
@@ -0,0 +1,90 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test';
2
+ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import path from 'node:path';
5
+ import { simulateReadableStream } from 'ai';
6
+ import { MockLanguageModelV4 } from 'ai/test';
7
+ import {
8
+ AgentModelCatalogSchema,
9
+ createMemoryAgentModelSelectionStore,
10
+ } from 'stitchkit/agent-runtime';
11
+ import { createStarterHarness } from '../src/runtime';
12
+
13
+ const roots: string[] = [];
14
+
15
+ afterEach(async () => {
16
+ await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
17
+ });
18
+
19
+ describe('Agent starter runtime', () => {
20
+ test('runs one model turn and reopens its durable transcript', async () => {
21
+ const workspace = await mkdtemp(path.join(tmpdir(), 'stitchkit-agent-starter-'));
22
+ roots.push(workspace);
23
+ await mkdir(path.join(workspace, 'instructions'));
24
+ await mkdir(path.join(workspace, 'skills'));
25
+ await writeFile(path.join(workspace, 'instructions/AGENTS.md'), 'Answer directly.\n');
26
+ const usage = {
27
+ inputTokens: { total: 1, noCache: 1, cacheRead: undefined, cacheWrite: undefined },
28
+ outputTokens: { total: 1, text: 1, reasoning: undefined },
29
+ };
30
+ const model = new MockLanguageModelV4({
31
+ doStream: {
32
+ stream: simulateReadableStream({
33
+ chunks: [
34
+ { type: 'text-start', id: 'answer' },
35
+ { type: 'text-delta', id: 'answer', delta: 'Ready to build.' },
36
+ { type: 'text-end', id: 'answer' },
37
+ { type: 'finish', finishReason: { unified: 'stop', raw: undefined }, usage },
38
+ ],
39
+ }),
40
+ },
41
+ });
42
+ const config = { apiKey: 'fixture' };
43
+ const descriptor = {
44
+ provider: 'fixture',
45
+ modelId: 'fixture/model',
46
+ contextWindow: 32_000,
47
+ capabilities: ['tools'],
48
+ };
49
+ const catalog = AgentModelCatalogSchema.parse({
50
+ schemaVersion: 1,
51
+ source: 'fixture',
52
+ observedAt: '2026-08-30T00:00:00.000Z',
53
+ completeness: 'complete',
54
+ diagnostics: [],
55
+ models: [{ id: 'fixture/model', name: 'Fixture', descriptor, metrics: [] }],
56
+ });
57
+ const selections = createMemoryAgentModelSelectionStore();
58
+ await selections.save('main', {
59
+ modelId: 'fixture/model',
60
+ selectedAt: '2026-08-30T00:00:00.000Z',
61
+ });
62
+ const provider = { create: () => model };
63
+ const diagnostics = { write: () => undefined };
64
+ const first = await createStarterHarness(config, workspace, {
65
+ catalog,
66
+ selections,
67
+ provider,
68
+ diagnostics,
69
+ });
70
+ const result = await first.harness.submit({
71
+ conversationId: 'main',
72
+ idempotencyKey: 'first',
73
+ context: {},
74
+ parts: [{ type: 'text', text: 'Hello' }],
75
+ metadata: { modelId: 'fixture/model' },
76
+ }).result;
77
+ expect(result.reason).toBe('success');
78
+ expect(result.message.parts).toContainEqual({ type: 'text', text: 'Ready to build.' });
79
+ await first.harness.close();
80
+
81
+ const reopened = await createStarterHarness(config, workspace, {
82
+ catalog,
83
+ selections,
84
+ provider,
85
+ diagnostics,
86
+ });
87
+ expect((await reopened.harness.snapshot('main')).messages).toHaveLength(2);
88
+ await reopened.harness.close();
89
+ });
90
+ });
@@ -0,0 +1,16 @@
1
+ {
2
+ "compilerOptions": {
3
+ "lib": ["ESNext", "DOM"],
4
+ "target": "ESNext",
5
+ "module": "ESNext",
6
+ "moduleResolution": "bundler",
7
+ "jsx": "react-jsx",
8
+ "jsxImportSource": "@opentui/react",
9
+ "strict": true,
10
+ "noUncheckedIndexedAccess": true,
11
+ "exactOptionalPropertyTypes": true,
12
+ "skipLibCheck": true,
13
+ "types": ["bun"]
14
+ },
15
+ "include": ["src", "tests"]
16
+ }