Skip to content

feat(harness): the harness creates the agent, then the chat starts [SAP-2981] - #746

Merged
gwitwer merged 11 commits into
mainfrom
georgewitwer/sap-2981-create-agent-flow
Aug 31, 2026
Merged

feat(harness): the harness creates the agent, then the chat starts [SAP-2981]#746
gwitwer merged 11 commits into
mainfrom
georgewitwer/sap-2981-create-agent-flow

Conversation

@gwitwer

@gwitwer gwitwer commented Aug 30, 2026

Copy link
Copy Markdown
Collaborator

Primary change type

  • Bug fix
  • Documentation
  • Feature
  • Tests
  • Dependency update
  • Maintenance or refactor

Problem and motivation

project-create-agent-*handleScaffoldSession created a pty session and then injected an English sentence into the terminal asking the coding agent to please call an MCP tool. The error copy admitted it: "Ask the coding agent to call sapiom_dev_agents_scaffold." There was no server-side scaffold endpoint — the top CTA, the project +, the empty-project CTA and the Templates gallery all ended in prompt injection, in three near-identical copies of the same sentence.

So a failed create surfaced as a confused model rather than an error, and "did it work?" could only be answered by reading a terminal.

Captured on the real 76-agent install, before this change. Clicking Create an agent in probes:

before — the injected sentence

…and what came back:

before — the confused model

probes/ isn't itself an agent project — it's a folder holding ~17 of them … So the scaffold refused: DIR_NOT_EMPTY. It needs a fresh empty subdirectory … Two things I need from you before I can do the rest: 1. Directory/agent name …

The button was not merely indirect; on any project that already holds agents it could not work at all, because the scaffold was aimed at the project folder.

Summary and scope

The harness creates the agent.

POST /api/agents/scaffold{ root, name, template? }{ path, name, template, dependenciesInstalled }. It calls the same scaffold routine the MCP tool calls, and refuses on its own findings, mirroring POST /api/agents/move (the closest existing precedent for a harness-owned filesystem mutation):

guard refusal failure it prevents
name is one plain segment (childPath) 400 ../evil, a/b, "", .hidden reaching fs
template is a plain segment 400 resolveTemplate joins it onto the bundled templates dir
root matched against the directories the rail can show — and the list's entry is what gets written into 409 the endpoint becoming an arbitrary-path mkdir
destination lstat 409 a plain directory the registry cannot see
registry lookup 409 a duplicate, named ("already has an agent called x")
fs.rm on a scaffold that throws 500 a half-created agent, which is worse than a refusal

onScaffolded rescans and broadcasts before the response, so the agent is in the rail before the caller can open a session on it.

The flow. + opens a dialog: name, starter, and the project stated, not chosen — you clicked that row.

after — the dialog

A refusal lands in the dialog, under the field that produces it, and nothing starts:

after — a duplicate is refused
after — an invalid segment, as you type

Then the agent is in the rail and the session opens on it, with the optional first instruction — which says the scaffold is done, so the agent never re-scaffolds a folder that already exists:

after — created, then chatting

The whole flow end to end (real server, real 76-agent tree):

https://raw.githubusercontent.com/sapiom/sapiom-js/pr-media/media/746/create-flow.webm

Three prompt constructions collapsed to one. The project +, the empty-project CTA, the bare-project affordance and the gallery's bundled starters all go through the endpoint (E4.6 — two creation paths for one operation is how they drift). The composer keeps its prompt, per the design's carve-out: no project, no name, and a folder that does not exist yet, so there is no row to create in and nothing for a dialog to state. Cloning a gallery template still goes through the coding agent — forking a published agent over the network is a different operation with a different failure mode, and the harness has no route for it.

One name rule, two guards. src/shared/agent-name.ts is imported by both the dialog and the route, because a name the field accepts and the server rejects reads as a broken app.

Two defects found by measuring, fixed here:

  1. The empty-project CTA was inert. .workspace-row-empty { pointer-events: none } predates the row carrying a button; once Create the first agent here moved in, that rule swallowed every click. elementFromPoint over the middle of the button returned the enclosing .workspace-group. The spec beside it asserted only toBeEnabled() — which a control nobody can click passes.
  2. A wire dump shown to the user. The dialog rendered POST /api/agents/scaffold → 409: {"error":"…"}. ApiError.reason is the server's own sentence; one errorMessage() helper now prefers it.

Also: the rail scrolls a newly focused agent into view (block: "nearest"). Measured — creating in probes left the rail parked on social-content, three screens from the row the create had just added.

Found after opening this PR, by continuing to measure (each proven both ways):

  • Return on Cancel created the agent. The dialog took Return for the whole form, so a focused Cancel took it too — it closed the dialog and created. With the guard removed, the new spec's "nothing created" assertion fails.
  • The dialog escaped a short window. At 620px tall its header — title and close button — sat 19px above the viewport, on a dialog that cannot be scrolled to. The frame is capped and the body scrolls; measured again at 950 / 700 / 620.
  • The bare-project door could bind to a dead session. It names the session that was in that folder when the dialog opened, and a dialog outlives a pty. Only a live one is reused now.
  • CodeQL read childPath's dirname(...) === root comparison as no barrier and flagged every fs call below it. The join now re-asserts containment at the sink with resolveWithinRoot — the analyzer-recognized shape, already used by server/canvas.ts — the failed-scaffold cleanup moved out of the handler, and the route took the same rate limiter shape the attachment upload has. Said plainly: stubbing that second barrier leaves the suite green. Nothing childPath returns can fail it, so it is defence in depth and a static-analysis barrier, not a second reachable rule. The cleanup's own mutation does fail.

Both hosts. The templates directory is resolved through one helper that applies the asar translation (scaffold copies with cpSync, which cannot opendir inside app.asar) — the demo seed's hand-written copy of that regex is now a call to it. The best-effort npm install shells out to npm, which the desktop host already puts on process.env.PATH before startServer() for exactly this reason (harness-desktop/src/main/runtime-shims.ts); if it is ever missing the scaffold still succeeds and the Canvas degrades to its existing "run npm install" hint.

Review round 1 — four findings, all real, all fixed

  • The cleanup could delete an agent it did not create. lstat is a message, not a lock: two POSTs with the same body both passed it, one scaffolded, and the loser's DIR_NOT_EMPTY cleanup recursively deleted the winner's freshly installed project while the winner's caller had been told it exists. The destination is claimed with a plain non-recursive mkdir now — the filesystem decides who gets the name — and the recursive delete is entitled to run only because this request created the directory. New spec fires both requests at once and reads the winner's file back; it fails against the old ordering. Confirmed on the real server: 409 probes already contains raced. + 200, with the winner's AGENTS.md/index.ts/node_modules intact.
  • The gallery's starters were refused on a fresh install. The SPA puts a new project under AppState.defaultProjectRoot (<launchDir>/projects under Electron), which the host never persists — so it was in none of the lists the route accepts, and the flow could not bootstrap (recentDirs learns a root only once a session exists there, and creation now runs first). The host's default is part of listProjectDirs. It was invisible because the mock had the name and duplicate guards but not the root barrier; it has it now, from the same list — removing the default root from that list fails templates.spec.ts, which is the fresh install reproduced.
  • A dead branch with a test certifying it. useTemplatePrompt's starter half became unreachable when starters moved onto the endpoint. Deleted, with the test that asserted the exact arguments of a handoff nothing performs.
  • A trailing dot is refused (Windows turns foo. into foo, so the path returned would not be the directory on disk), and the dialog's never-passed triggerRef is gone rather than left undocumented — every control that opens it unmounts on use.

Review round 2 — one finding, and it was right

The atomic claim fixed the delete and introduced a new dead end at the same click: <launchDir>/projects is the desktop host's default parent for new projects and nothing creates it — the scaffold's own recursive mkdir did, until the non-recursive claim started running ahead of it. A fresh install's first template was still refused at its own suggested destination, with a new sentence.

The claim makes the parent first — recursive, and only on the directory the rail's list already vetted — so the claim itself stays a single non-recursive mkdir on the agent's own name, which is what makes it exclusive. The spec that pinned the wrong shape ("refuses when the project directory has been deleted under it") was the bug written down as a rule; it is now "creates the project directory when it does not exist yet". Both mutations fail as they must: drop the parent mkdir and the fresh-install spec fails; make the claim recursive again and the concurrent-create spec fails. Verified on a real server with projectRoot pointed at a path that did not exist — the create made it and scaffolded into it.

Out of scope: the gallery clone path, the composer's prompt, and any change to the map or the graph (SAP-2983 owns those files).

Related work

Related issue or discussion: SAP-2981 · design: plans/studio-project-experience/design.md § E4 · follows #740 (SAP-2982) and #742 (SAP-2980).

Validation

pnpm --filter @sapiom/harness test        — 2654/2655 unit + 10/10 perf. The single
                                            failure is src/server/system-graph-freshness.test.ts,
                                            red on clean main on macOS (a filesystem-watcher
                                            test from #724); Linux CI reports it green.
pnpm --filter @sapiom/harness typecheck   — clean (tsc --noEmit ×2)
pnpm --filter @sapiom/harness lint        — clean (1 pre-existing warning in rest.test.ts)
pnpm terminology:check                    — passed (460 files); allowlist count for the mock's
                                            workflows.changed bumped 1 → 2 (the mock's scaffold
                                            announces it, like the mock's mover)
pnpm examples:check                        — OK
E2E_PORT=5465 …/test:ui                   — 470/470 on the final code.

Driven against a real server/Users/gwitwer/sapiom/agents (76 agents / 9 projects), own port, own --state-root. Every refusal was posted directly to the endpoint, dialog bypassed:

{"name":"../evil"}                → 400  An agent name is one folder name — it can't contain / or \.
{"name":""}                       → 400  Give the agent a name.
{"name":"/tmp/evil"}              → 400  (same)                     nothing at /tmp/evil
{"name":".hidden"}                → 400  … can't start with a dot — a dotted folder is hidden from the rail.
{"template":"../../../etc"}       → 400  Unknown template '../../../etc'.
{"root":"/tmp"}                   → 409  Studio doesn't show that folder as a project.
{"name":"hello-world"}            → 409  probes already has an agent called hello-world.
{"root":"probes"}                 → 400  root must be an absolute path

Filesystem verified after every refusal: probes unchanged at 16 entries, no evil anywhere. Happy path: 2.3 s including npm install; the agent was in GET /api/workflows (77) before the response returned.

Ordering, measured in the browser (poll every 50 ms): the rail row appeared at 1.3–4.1 s, the first session at 2.1–4.6 s — the agent is in the rail ~0.6 s before any session exists, on every run.

Mutation-tested, every guard. Server (scaffold.test.ts): neutering the name rule, the template guard, the root barrier, the registry duplicate check, the disk lstat, or the failed-scaffold cleanup each fails the suite. E2E (create-agent.spec.ts): creating the session before the scaffold, swallowing the refusal, dropping the name rule, sending the old scaffold prompt, or putting the rail's create back on a session each fails the suite.

One assertion did not fail its own mutation and was rewritten. "Rescans before it answers" survived awaitvoid, because a synchronous stub records "scanned" first either way. The stub now awaits a real tick before recording; the mutation then fails, as it must.

Tests and documentation

src/server/scaffold.test.ts (13, real filesystem, route posted directly), src/shared/agent-name.test.ts (3), web/e2e/create-agent.spec.ts (6). rail-grammar.spec.ts's create test and templates.spec.ts's starter test asserted the old mechanism and now assert the new contract — both by order, not by counting. The REST surface block in src/shared/types.ts gains the scaffold route (and the move route, which was already missing).

Compatibility and release impact

  • Breaking or externally visible changes: new endpoint POST /api/agents/scaffold; the project + and the gallery's bundled starters no longer start a session before creating. A starter now lands under a folder Studio shows as a project — a destination outside one is refused, deliberately, with a reason.
  • Changeset: Added — .changeset/create-agent-flow.md.

Security

  • I have not included secrets, credentials, private data, or unsanitized logs.
  • This pull request does not publicly disclose a suspected vulnerability. I
    will follow the
    Security Policy for
    private reporting.

AI assistance

  • I did not use AI assistance for this change.
  • I used AI assistance and have described it below.

Claude Code (Opus 5) wrote the endpoint, the dialog, the specs and this description, and drove the verification above — a real harness on its own port and state root, Playwright against the real 76-agent tree, direct curl at every refusal, and a mutation for every guard. Findings are quoted from what actually ran; the one assertion that survived its own mutation is named above.

Checklist

  • I read CONTRIBUTING.md, and this contribution follows the direct-PR or issue-first policy.
  • This pull request addresses one focused problem and contains no unrelated cleanup.
  • I added or updated tests, or explained above why tests are not applicable.
  • I ran the relevant build, typecheck, lint, and test commands, or explained
    any N/A checks above.
  • I updated documentation for user-facing changes, or marked it N/A above.
  • I added a Changeset for a published-package change, or explained why it is not applicable.
  • I can explain and maintain every submitted change, including any AI-assisted work.

gwitwer and others added 5 commits August 30, 2026 09:26
…old [SAP-2981]

Every create door in the Studio ended 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. This is the missing server-side create.

It runs the same `scaffold` routine the MCP tool runs and refuses on its own
findings, mirroring POST /api/agents/move: one plain segment for the name, a
plain segment for the template (`resolveTemplate` joins it onto the bundled
templates dir), a root matched against the same directory list the move route
drops into, and an lstat of the destination. A failed scaffold removes the
directory it created — a half-created agent is worse than a refusal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019fEqzA8eEEdQsmEKzLNnuf
The project + opens a dialog: a name, a starter, and the project it lands in
stated rather than chosen — you clicked that row, that is the answer. Submit
scaffolds through POST /api/agents/scaffold, the agent joins the rail, and only
then does a session open on it with the optional first instruction.

Collapses the three copies of the injected scaffold sentence to one: the
composer keeps a prompt (no project, no name, a folder that does not exist
yet), while the project +, the empty-project CTA, the bare-project affordance
and the bundled starters in the template gallery all go through the endpoint.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019fEqzA8eEEdQsmEKzLNnuf
…iled request [SAP-2981]

Measured on a real server: the create dialog rendered
`POST /api/agents/scaffold → 409: {"error":"probes already has an agent called
hello-world."}` — a log line with a JSON body inside it. ApiError already
carries `.reason`, the server's own sentence; every user-facing catch now goes
through one helper that prefers it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019fEqzA8eEEdQsmEKzLNnuf
…TA [SAP-2981]

Specs for the create flow in mock mode, asserting the ORDER (one append-only
log holding both halves of a create) rather than two counters that each say a
thing happened and neither says which came first. Every guard was
mutation-tested: creating the session first, swallowing the refusal, dropping
the name rule, sending the old scaffold prompt, and putting the rail's create
back on a session all fail the suite.

Driving the empty-project CTA for the first time found it inert: the row
carries `pointer-events: none` from when it was a label, and once "Create the
first agent here" moved in, that rule swallowed every click on it.
`elementFromPoint` over the middle of the button returned the enclosing
`.workspace-group`. The spec beside it asserted only `toBeEnabled()`, which a
control nobody can click passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019fEqzA8eEEdQsmEKzLNnuf
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019fEqzA8eEEdQsmEKzLNnuf
Comment thread packages/harness/src/server/scaffold.ts Fixed
Comment thread packages/harness/src/server/scaffold.ts Fixed
Comment thread packages/harness/src/server/scaffold.ts Fixed
gwitwer and others added 4 commits August 30, 2026 10:09
…he create route [SAP-2981]

CodeQL flagged the new route: `childPath`'s dirname comparison is not a barrier
it recognizes, so every fs call below it read as path injection, and the
inline cleanup made the handler itself an unbounded filesystem sink.

The join now re-asserts containment at the sink with `resolveWithinRoot` — the
analyzer-recognized shape, and the one `server/canvas.ts` already uses — and
the failed-scaffold cleanup moved into its own function. A create is the most
expensive request this server serves (a template copy, npm install, git init),
so it also gets the same rate limiter shape the attachment upload has.

Said plainly: stubbing the resolveWithinRoot barrier leaves the suite green.
Nothing childPath returns can fail it, so it is defence in depth and a static-
analysis barrier, not a second reachable rule. The cleanup's own mutation does
fail, as it must.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019fEqzA8eEEdQsmEKzLNnuf
The bare-project door names the session that was in that folder when the
dialog opened, and a dialog can stay open longer than a pty lives. Binding
the new agent to an exited session would leave it with nothing to talk to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019fEqzA8eEEdQsmEKzLNnuf
The dialog took Return for the whole form, so a focused Cancel took it too:
pressing Return on Cancel closed the dialog AND created the agent. Measured
both ways — with the guard removed the spec's "nothing created" assertion
fails.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019fEqzA8eEEdQsmEKzLNnuf
Measured: four blocks make it the tallest dialog in the app (658px), and a
620px-tall window pushed its header — title and close button — 19px above the
viewport, on a dialog that cannot be scrolled to. The frame is capped and the
body scrolls; header and actions stay put at every height measured
(950 / 700 / 620).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019fEqzA8eEEdQsmEKzLNnuf
@github-actions

Copy link
Copy Markdown
Contributor

Review — PR #746 (feat(harness): the harness creates the agent, then the chat starts)

No confidentiality problems. The changeset, JSDoc, prompts and fixtures are all generic
(acme-app, /Users/demo, "your coding agent"); no third-party company, business
arrangement, or internal host/service is named. minor is the right level for a new
endpoint plus additive UI, and no new dependency ships (express-rate-limit was already a
runtime dep). AgentScaffoldResponse is a reasonable, small surface addition.

1. The template gallery's bundled starters are refused on a fresh install

handleUseTemplate (web/src/App.tsx:1565) now derives parent = parentOf(cwd) and posts
it as the scaffold root. cwd defaults to templateDirSuggestion(template, projectRoot)
= <resolveProjectRoot()>/<template.id>, so parent is the resolved project root.

resolveProjectRoot falls back to the server-supplied defaultProjectRoot
(options.projectRoot ?? launchDir, src/server/index.ts:1535), which the desktop host
sets to <launchDir>/projects (harness-desktop/src/main/boot.ts:533) and never
persists
boot.ts writes only telemetryOptIn into settings.json.

The route's listProjectDirs (src/server/index.ts:1750-1760) reads only
stored.recentDirs, stored.projectRoot, live session cwds, and branch dirs of known
agents. On a first run — nothing saved, no agents under <launchDir>/projects yet, boot
session rooted at launchDir — that folder is in none of them.

Failure: fresh desktop install → Templates → "Coding pause" → Use → accept the
prefilled destination → 409 Can't create an agent in /…/projects — Studio doesn't show that folder as a project. Before this PR the same click started a session and worked.
recentDirs only learns the root after a session is created there, and the scaffold now
runs first, so the flow cannot bootstrap itself.

This is invisible to the suite because MockApi.scaffoldAgent (web/src/lib/api.ts:2201)
implements the name and duplicate guards but not the project-root barrier — its own
comment says "the same guard set minus the lstat", which is one guard short.
templates.spec.ts therefore asserts a scaffold at
/Users/demo/acme-app/projects/coding-pause succeeds where the real route refuses it.

Fix: feed the host's options.projectRoot into listProjectDirs (or persist it at boot),
and add the root barrier to the mock so the e2e can see it.

2. removeFailedScaffold can rm -rf a directory this request did not create

src/server/scaffold.ts:686 runs fs.rm(target, { recursive: true, force: true }) on
any rejection from scaffoldAgent, justified by "every guard above proved the
destination was absent, so whatever is there now is this attempt's own wreckage." That
holds only if nothing else can occupy target between the lstat and the scaffold.

Failure: two POSTs with the same {root, name} (the route is directly postable and the
limiter allows 30/min). Both pass lstat; A's scaffold creates the project, installs deps
and returns 200; B's scaffold hits isScaffoldableTarget → throws DIR_NOT_EMPTY; B's
cleanup recursively deletes A's agent, and A's caller has already been told it exists. The
same happens if the user creates that folder in Finder mid-flight — the very "a folder the
user never made" case the cleanup is written against, inverted.

Fix: skip the cleanup when the error is DIR_NOT_EMPTY (agent-core throws
AgentOperationError with that code), or have scaffoldAgent report whether this call
performed the mkdir and only clean up when it did. scaffold.test.ts covers the
create-then-throw case but not this one.

Nits

  • useTemplatePrompt's starter branch (web/src/lib/templates.ts:252-257) is now
    unreachable — its only caller returns early for kind === "starter" — yet its doc still
    says starters go through the scaffold MCP tool and templates.test.ts:258 still guards
    it. Dead code with a test that certifies it.
  • CreateAgentDialog declares triggerRef "Escape returns focus to it"; App.tsx:2841
    never passes it, so Escape drops focus to <body>.
  • refuseAgentName rejects leading/trailing spaces but not a trailing dot. On Windows
    mkdir foo. creates foo, so the response's path/name and the directory on disk
    disagree and the SPA focuses and binds a path that does not exist.

Verdict

Request changes — the endpoint and its guards are well built and well tested, but the
template-starter path is broken on a fresh install by a project-root list the host default
is missing from, and the failed-scaffold cleanup is an unconditional recursive delete.

…fault root, no dead branch [SAP-2981]

Four findings from the automated review, each fixed and each proven.

**The cleanup could delete an agent it did not create.** `lstat` is a message,
not a lock: two POSTs with the same body both passed it, one scaffolded, and
the other's DIR_NOT_EMPTY cleanup recursively deleted the winner's freshly
installed project while the winner's caller had been told it exists. The
destination is now CLAIMED with a plain non-recursive `mkdir` — the filesystem
decides who gets the name — and the recursive delete is entitled to run only
because this request created the directory. The new spec fires both requests at
once and reads the winner's file back; it fails against the old ordering.

**The template gallery's starters were refused on a fresh install.** The SPA
puts a new project under `AppState.defaultProjectRoot` (`<launchDir>/projects`
under Electron), which the host never persists — so it was in none of the lists
the create route accepts, and the flow could not bootstrap: `recentDirs` learns
a root only once a session exists there, and creation now runs first. The
host's default is part of `listProjectDirs` now.

That was invisible because the mock implemented the name and duplicate guards
but not the root barrier — a mock missing a guard is a suite that certifies the
bug. It has the barrier now, built from the same list; removing the default
root from it fails `templates.spec.ts`, which is the fresh install reproduced.

**A dead branch with a test certifying it.** `useTemplatePrompt`'s starter half
became unreachable when starters moved onto the endpoint. Deleted, along with
the test that asserted the exact arguments of a handoff nothing performs.

**A trailing dot** is refused: Windows turns `foo.` into `foo`, so the path the
caller is told it got would not be the directory on disk. And the dialog's
`triggerRef` is gone rather than undocumented — every control that opens it
unmounts on use, so the ref would name a detached node.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019fEqzA8eEEdQsmEKzLNnuf
@github-actions

Copy link
Copy Markdown
Contributor

Follow-up review — PR #746 (delta since 7f5108b7)

One commit (ff9f7562). No new confidentiality or public-copy problems; no package
manifest, dependency, or changeset changes in the delta.

1. Finding 1 is half fixed — the fresh-install starter now fails at the claim instead of the barrier

defaultProjectRoot is now in listProjectDirs (src/server/index.ts:1761), so the root
barrier passes. But claimTarget (src/server/scaffold.ts:163) is a non-recursive
fs.mkdir(target), and on a fresh desktop install <launchDir>/projects has never been
created: boot.ts:205 mkdirs only launchDir, projectRoot: path.join(launchDir, "projects") (boot.ts:533) is passed but never made, and no sample seed creates it.
Previously the only thing that created it was agent-core's mkdirSync(targetDir, { recursive: true }) (packages/agent-core/src/scaffold.ts:345) — which the claim now runs
ahead of.

Failure: fresh install → Templates → "Coding pause" → Use → accept the prefilled
destination → 409 Can't create an agent in /…/projects — that folder no longer exists.
Same click, same dead end, new sentence. The new spec "refuses when the project directory
has been deleted under it"
(scaffold.test.ts:335) pins exactly this shape, so the suite
reads it as correct; MockApi has no filesystem, so templates.spec.ts still passes.

Fix: await fs.mkdir(path.dirname(target), { recursive: true }) on the list's entry
immediately before the claim (the parent is already vetted, and creating only a vetted
project root keeps the claim itself exclusive), or mkdir projectRoot at boot.

2. Earlier findings that the push did fix

Finding 2 (unconditional rm -rf) is genuinely resolved: claimTarget's non-recursive
mkdir makes the cleanup act only on a directory this request created, and
scaffold.test.ts:302 proves the loser of a concurrent create deletes nothing. All three
nits (dead starter branch, unused triggerRef, trailing dot) are fixed. Nothing the
earlier round claimed was wrong.

Verdict

Request changes — one root cause remains: the create route can no longer create its own
project root, which is the fresh-install default.

…at [SAP-2981]

Round two of the review, and it is right: the atomic claim fixed the delete but
introduced a new dead end at the same click. `<launchDir>/projects` is the
desktop host's default parent for new projects and NOTHING creates it — the
scaffold's own recursive mkdir did, until the non-recursive claim started
running ahead of it. A fresh install's first template was refused at its own
suggested destination, with a new sentence: "that folder no longer exists".

The claim now makes the parent first — recursive, and only on the directory the
rail's list already vetted — so the claim itself stays a single non-recursive
mkdir on the agent's own name, which is what makes it exclusive.

The spec that pinned the wrong shape ("refuses when the project directory has
been deleted under it") was the bug written down as a rule; it is now "creates
the project directory when it does not exist yet". Both mutations fail as they
must: drop the parent mkdir and the fresh-install spec fails; make the claim
recursive again and the concurrent-create spec fails.

Verified on a real server: with `projectRoot` pointed at a path that did not
exist, the create made it and scaffolded into it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019fEqzA8eEEdQsmEKzLNnuf
@gwitwer
gwitwer merged commit fc231c9 into main Aug 31, 2026
10 of 11 checks passed
@gwitwer
gwitwer deleted the georgewitwer/sap-2981-create-agent-flow branch August 31, 2026 03:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants