Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .changeset/create-agent-flow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
"@sapiom/harness": minor
---

Studio: creating an agent in a project is now a form that creates it, not a
message asking your coding agent to.

Every create door ended the same way — a session started and an English
sentence was typed into the terminal asking the coding agent to please call a
scaffold tool. Studio never created anything, so a failure arrived as a
confused model rather than an error, and "did it work?" could only be answered
by reading a terminal. On a project that already held agents it simply could
not work: the scaffold was aimed at the project folder, which is not empty, and
the reply was a paragraph asking you which subdirectory you meant.

- **Create an agent in {project}** opens a small dialog: a name, a starter, and
the project it lands in — stated, not asked again, because you clicked that
row. Submit and Studio creates the agent itself.
- **Creation completes before the chat starts.** The agent is on disk and in
your rail before a session opens on it, and the rail scrolls it into view. A
first instruction is optional, and the session opens on that instead of on a
request to scaffold.
- **A refusal is a sentence in the dialog.** A name already taken in that
project, a name that is not a folder name, a folder Studio does not show as a
project — each is refused with a reason you can act on, and nothing
half-created is left behind if the scaffold itself fails.
- **The bundled starters in the template gallery take the same path**, so the
two ways of starting from a starter cannot drift. Cloning a published
template still goes through your coding agent, which is a different operation
with a different failure mode.
- The empty-project row's **Create the first agent here** now responds to a
click; it had been unclickable.
- Starting from an idea on the home screen is unchanged: no project, no name,
and a folder that does not exist yet, so it stays a conversation.

New endpoint `POST /api/agents/scaffold` — `{ root, name, template? }` → the
created agent's path. It runs the same scaffold the CLI does and refuses on its
own findings rather than on the caller's word.
22 changes: 22 additions & 0 deletions packages/harness/src/core/agent-core-templates.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/**
* Where `@sapiom/agent-core`'s bundled starter templates live on disk.
*
* One resolver, because there are now two callers that scaffold a real project
* — the demo seed (`core/example-seed.ts`) and `POST /api/agents/scaffold` —
* and both need the same two corrections. `scaffold()` takes `templatesDir`
* explicitly when its caller is ESM (there is no `__dirname` to resolve the
* bundled `templates/` from), and the packaged app needs the asar translation:
* `scaffold` COPIES the template with `cpSync`, which cannot `opendir` inside
* `app.asar` (ENOTDIR) no matter what Electron patches.
*/
import { createRequire } from "node:module";
import * as path from "node:path";

import { unpackedPath } from "./asar-path.js";

const nodeRequire = createRequire(import.meta.url);

export function agentCoreTemplatesDir(): string {
const entry = nodeRequire.resolve("@sapiom/agent-core");
return unpackedPath(path.resolve(path.dirname(entry), "..", "..", "templates"));
}
15 changes: 1 addition & 14 deletions packages/harness/src/core/example-seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@
import { execFileSync } from "node:child_process";
import { existsSync } from "node:fs";
import * as fs from "node:fs/promises";
import { createRequire } from "node:module";
import * as path from "node:path";

import {
Expand All @@ -47,23 +46,11 @@ import {
type ResolvedVersions,
} from "@sapiom/agent-core";

import { agentCoreTemplatesDir } from "./agent-core-templates.js";
import { TEMPLATE_HTML, renderCanvasDocument } from "./canvas-template.js";

const nodeRequire = createRequire(import.meta.url);

export const SAMPLE_PROJECT_NAME = "order-triage";

/** Locate @sapiom/agent-core's bundled templates dir (no `__dirname` in ESM). */
function agentCoreTemplatesDir(): string {
const entry = nodeRequire.resolve("@sapiom/agent-core");
const dir = path.resolve(path.dirname(entry), "..", "..", "templates");
// Embedded in Electron, require.resolve reports the app.asar (virtual) path;
// scaffold()'s cpSync can't opendir inside the asar archive (ENOTDIR), so
// point at the unpacked twin. No-op under the CLI (real filesystem path).
// The desktop host must asarUnpack node_modules (it unpacks all of them).
return dir.replace(/([\\/])app\.asar([\\/])/, "$1app.asar.unpacked$2");
}

function tryGit(cwd: string, args: string[]): boolean {
try {
execFileSync("git", args, { cwd, stdio: "ignore", windowsHide: true });
Expand Down
70 changes: 69 additions & 1 deletion packages/harness/src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { readFileSync } from "node:fs";
import { dirname, join, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url";
import express, { type Express } from "express";
import { scaffold } from "@sapiom/agent-core";
import { WebSocketServer } from "ws";
import open from "open";

Expand Down Expand Up @@ -87,6 +88,7 @@ import {
removeGeneratedSessionDir,
sweepGeneratedDirs,
} from "../core/inject/retention.js";
import { agentCoreTemplatesDir } from "../core/agent-core-templates.js";
import { CanvasWatcherManager } from "../core/canvas-watcher.js";
import { WorkspaceWatcherManager } from "../core/workspace-watcher.js";
import { InstallWatcherManager } from "../core/install-watcher.js";
Expand Down Expand Up @@ -150,6 +152,7 @@ import {
moveTargetDirs,
remapSessions,
} from "./agent-move.js";
import { createAgentScaffoldRouter } from "./scaffold.js";
import { createMacrosRouter } from "./macros.js";
import { createFsRouter } from "./fs.js";
import { createRunsRouter } from "./runs.js";
Expand Down Expand Up @@ -319,6 +322,7 @@ type WorkflowScanReason =
| "session-create"
| "workspace-change"
| "agent-linked"
| "agent-created"
| "agent-moved"
| "graph-refresh"
| "requested";
Expand Down Expand Up @@ -500,6 +504,11 @@ export const startServer = async (
// already exists or when HOME is unwritable.
await migrateHarnessIdentity(statePaths.machineId);
const launchDir = options.launchDir ?? process.cwd();
/** Where a NEW agent project goes before the user saves a `projectRoot` of
* their own — the host's answer (`<launchDir>/projects` under Electron),
* reported to the SPA as `AppState.defaultProjectRoot` and counted as a
* place the create route may write (see `listProjectDirs` below). */
const defaultProjectRoot = options.projectRoot ?? launchDir;

// Serve-time slug enrichment: resolves each workflow's definitionSlug from
// the Sapiom Agents API when it's absent (deployed sapiom.json files carry
Expand Down Expand Up @@ -1528,7 +1537,7 @@ export const startServer = async (
});
},
launchDir,
defaultProjectRoot: options.projectRoot ?? launchDir,
defaultProjectRoot,
agentsBaseUrl: resolveAgentsBaseUrl(),
availableHarnesses: options.availableHarnesses,
listTasks: () => taskManager.list(),
Expand Down Expand Up @@ -1731,6 +1740,65 @@ export const startServer = async (
},
}),
);
// SAP-2981: the harness CREATES the agent. Every create door used to end in
// an English sentence injected into a terminal asking the coding agent to
// call the scaffold MCP tool, so a failed create surfaced as a confused model
// and "did it work?" was answered by reading a terminal. The route runs the
// same `scaffold` routine that tool runs, and its guards live in the module:
// one plain segment for the name, a plain segment for the template (which
// `resolveTemplate` JOINS onto the bundled templates dir), and a root matched
// against the SAME directory list the move route drops into — so "a folder
// the rail can show" and "a folder the studio will create a project in" stay
// one answer.
app.use(
createAgentScaffoldRouter({
listProjectDirs: async () => {
const stored = await loadSettings(statePaths.settings);
return moveTargetDirs(
[
...stored.recentDirs,
...(stored.projectRoot ? [stored.projectRoot] : []),
// THE HOST'S DEFAULT, which the move route does not need and this
// one does. `AppState.defaultProjectRoot` is where the SPA puts a
// NEW project when the user has saved no `projectRoot` of their own
// — `<launchDir>/projects` under Electron — and the host does not
// persist it into settings. Without it, the first template a user
// ever starts from is refused at its own suggested destination
// ("Studio doesn't show that folder as a project"), and the flow
// cannot bootstrap: `recentDirs` only learns a root once a session
// has been created there, and creation now happens FIRST.
...(defaultProjectRoot ? [defaultProjectRoot] : []),
...sessionManager.list().map((session) => session.cwd),
],
workflowsCache.map((w) => w.path),
);
},
resolveAgent: (agentPath) =>
workflowsCache.find((w) => resolve(w.path) === agentPath) ?? null,
scaffoldAgent: async ({ targetDir, template }) => {
// `installDependencies: true` for the same reason the MCP tool passes
// it: the Canvas bundles the project on its first, unprompted render
// and resolves `@sapiom/agent`/`zod` from the project's own
// node_modules, so a never-installed agent opens on a "Could not
// resolve …" error. Best-effort inside agent-core — a failed install
// still returns a created project.
const result = await scaffold({
targetDir,
template,
templatesDir: agentCoreTemplatesDir(),
installDependencies: true,
});
return { dependenciesInstalled: result.dependenciesInstalled };
},
// Rescan the PROJECT root, not the agent directory: the registry has to
// learn the new agent under the project the rail draws it in, and the
// scan broadcasts `workflows.changed` so the row is there before the
// dialog's caller opens a session on it.
onScaffolded: async (agentDir) => {
await scanWorkflowsAndBroadcast(dirname(agentDir), "agent-created");
},
}),
);
app.use(
createWorkflowsRouter(enrichedWorkflowRegistry),
createFsRouter(),
Expand Down
Loading
Loading