Skip to content

Commit 9f2fa0a

Browse files
authored
Merge pull request #67 from fastclaw-ai/feat/runtime-backends-and-preview-ux
feat: backend-agnostic project runtime + multi-template + preview UX
2 parents 0b4f757 + 418ce82 commit 9f2fa0a

18 files changed

Lines changed: 1191 additions & 132 deletions

File tree

cmd/fastclaw/main.go

Lines changed: 70 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -194,9 +194,14 @@ func runGateway(port int) error {
194194
// env-overridable so fastclaw stays template-agnostic — the default
195195
// targets a ShipAny image with the template baked at /template.
196196
if home, herr := config.HomeDir(); herr == nil {
197+
// backend + shared pool let the runtime host previews through the
198+
// agent's pooled executor on non-docker backends (e2b/boxlite);
199+
// docker keeps its dedicated-container path. Pool is nil when
200+
// sandboxing is disabled, which keeps the docker path.
197201
rtMgr := coderuntime.NewManager(
198202
gw.Store(), home, env.Sandbox.Image,
199-
&sandbox.Policy{}, os.Getenv("FASTCLAW_PREVIEW_BASE"))
203+
&sandbox.Policy{}, os.Getenv("FASTCLAW_PREVIEW_BASE"),
204+
env.Sandbox.Backend, gw.SandboxPool())
200205
rtMgr.RegisterTemplate("shipany-tanstack", coderuntime.TemplateSpec{
201206
DevPort: 3000,
202207
// Default scaffold, validated end-to-end against
@@ -217,14 +222,76 @@ func runGateway(port int) error {
217222
"--exclude=node_modules --exclude=.git --exclude=.output --exclude=dist "+
218223
"-cf - . | tar -C /workspace -xf -; fi; cd /workspace; "+
219224
"command -v pnpm >/dev/null 2>&1 || npm i -g pnpm; "+
225+
// Point pnpm at the shared store volume so installs after the
226+
// first reuse downloaded packages instead of re-fetching.
227+
"pnpm config set store-dir /pnpm-store 2>/dev/null || true; "+
220228
"pnpm config set verify-deps-before-run false; "+
221-
"pnpm install || true; pnpm rebuild"),
222-
DevCmd: envOr("FASTCLAW_SHIPANY_DEV", "pnpm dev --host 0.0.0.0 --port 3000"),
229+
"pnpm install || true; pnpm rebuild; "+
230+
// Snapshot the pristine template as a git baseline so the UI
231+
// can later show ONLY the files the agent changed (git status
232+
// vs this commit). .gitignore already excludes node_modules /
233+
// build output, so the diff stays clean.
234+
"git config --global --add safe.directory '*' 2>/dev/null || true; "+
235+
"git init -q 2>/dev/null || true; git add -A 2>/dev/null || true; "+
236+
"git -c user.email=bot@fastclaw -c user.name=fastclaw commit -q -m baseline 2>/dev/null || true"),
237+
// Self-heal pnpm before running: the base image ships node+npm
238+
// only, and pnpm is installed globally (container FS, NOT the
239+
// bind-mounted /workspace). On a wake/re-up the container is
240+
// recreated but the workspace already has files, so scaffold is
241+
// skipped and the global pnpm is gone — `pnpm dev` would then
242+
// fail "pnpm: not found". Ensuring it here makes DevCmd robust
243+
// across container recreation without re-running the scaffold.
244+
// Self-heal pnpm + its run-time config before starting dev. Both
245+
// live in the container FS (global pnpm install + ~/.pnpmrc), NOT
246+
// the bind-mounted /workspace, so a wake/re-up that recreates the
247+
// container loses them while scaffold is skipped (workspace
248+
// non-empty). Without `verify-deps-before-run false`, pnpm 11
249+
// re-runs a deps-status check before `dev` that fails against the
250+
// volume-mounted node_modules and aborts the server.
251+
// Self-heal pnpm, its config, AND deps before dev. All three live
252+
// outside the bind-mounted /workspace (global pnpm in container
253+
// FS; node_modules in a per-scope volume that Stop removes), so a
254+
// re-up after Stop recreates the container with files present
255+
// (scaffold skipped) but no deps — `pnpm dev` would then die with
256+
// "vite: not found". Reinstalling when node_modules/.bin/vite is
257+
// missing makes every boot self-correct (fast: the shared pnpm
258+
// store volume hard-links, no re-download).
259+
DevCmd: envOr("FASTCLAW_SHIPANY_DEV",
260+
"command -v pnpm >/dev/null 2>&1 || npm i -g pnpm; "+
261+
"pnpm config set verify-deps-before-run false 2>/dev/null || true; "+
262+
"[ -x node_modules/.bin/vite ] || pnpm install || true; "+
263+
"pnpm dev --host 0.0.0.0 --port 3000"),
223264
// Local template checkout bind-mounted at /template (option C):
224265
// set FASTCLAW_SHIPANY_TEMPLATE_DIR=~/code/shipany-tanstack to
225266
// scaffold from disk without baking the image or cloning.
226267
TemplateMount: os.Getenv("FASTCLAW_SHIPANY_TEMPLATE_DIR"),
227268
})
269+
// A second, lighter template demonstrating multi-template support:
270+
// a plain Vite + React + TS starter. It needs no baked /template and
271+
// no R2 source — it self-scaffolds with `npm create vite` — and shares
272+
// the SAME sandbox image as shipany (Node toolchain), so it proves the
273+
// "one e2b image, many templates differing only by ScaffoldCmd" model
274+
// (no per-template image needed for same-stack templates). Vite's HMR
275+
// client follows the page origin, so it works through both docker's
276+
// host-port map and e2b's <port>-<id>.e2b.app proxy with no extra
277+
// config. shipany-tanstack above is untouched.
278+
rtMgr.RegisterTemplate("vite-react", coderuntime.TemplateSpec{
279+
DevPort: 5173,
280+
ScaffoldCmd: envOr("FASTCLAW_VITE_REACT_SCAFFOLD",
281+
"set -e; cd /workspace; "+
282+
"if [ ! -f package.json ]; then "+
283+
"npm create vite@latest .fctmp -- --template react-ts && "+
284+
"cp -a .fctmp/. ./ && rm -rf .fctmp; fi; "+
285+
"npm install; "+
286+
// Git baseline so the UI's changed-files view diffs against
287+
// the pristine scaffold — same pattern as shipany above.
288+
"git config --global --add safe.directory '*' 2>/dev/null || true; "+
289+
"git init -q 2>/dev/null || true; git add -A 2>/dev/null || true; "+
290+
"git -c user.email=bot@fastclaw -c user.name=fastclaw commit -q -m baseline 2>/dev/null || true"),
291+
DevCmd: envOr("FASTCLAW_VITE_REACT_DEV",
292+
"[ -x node_modules/.bin/vite ] || npm install; "+
293+
"npm run dev -- --host 0.0.0.0 --port 5173"),
294+
})
228295
webSrv.SetRuntimeManager(rtMgr) // HTTP /runtime endpoints
229296
gw.SetProjectRuntime(rtMgr) // agent preview tools
230297
slog.Info("project runtime enabled",

docs/coding-agent-runtime.md

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,57 @@ Template commands are env-overridable so fastclaw stays template-agnostic:
145145

146146
To add another template (e.g. a Next.js starter), call
147147
`rtMgr.RegisterTemplate("my-template", coderuntime.TemplateSpec{…})`
148-
nothing in the runtime is ShipAny-specific.
148+
nothing in the runtime is ShipAny-specific. The **first** registered ref is
149+
the default the preview tool uses when the agent omits one, so registering
150+
more templates never changes an existing deployment's default. A second
151+
template ships out of the box — `vite-react`, a plain Vite + React + TS
152+
starter that self-scaffolds with `npm create vite` (no baked `/template`, no
153+
source fetch) — as a worked example of multi-template support.
154+
155+
## Sandbox backends (docker vs e2b/boxlite)
156+
157+
The preview path depends on `FASTCLAW_SANDBOX_BACKEND`:
158+
159+
- **docker (default)** — the runtime owns a dedicated long-lived container
160+
per project, publishes the dev port to a host port, and the agent's edits
161+
reach the dev server through a shared host bind mount. Unchanged.
162+
- **e2b / boxlite (cloud, no host mount)** — the runtime runs the dev server
163+
inside the **same pooled sandbox the agent writes files to** (so HMR works
164+
with no bind mount) and exposes the port via the backend's own URL scheme
165+
(e2b: `https://<port>-<sandboxID>.e2b.app`). Coding writes route to
166+
`workspace.Store`; for a remote-workspace backend they're additionally
167+
mirrored into the live sandbox so the dev server sees them.
168+
169+
### Cloud template provisioning (where `/template` comes from)
170+
171+
The scaffold needs the template source at `/template` in the sandbox. On a
172+
cloud backend there is no host bind mount, so pick one of:
173+
174+
1. **Bake into the sandbox image / e2b template (recommended).** Build the
175+
e2b template (or docker image) with the toolchain (node/pnpm + camoufox
176+
for copyweb) **and** the template at `/template` — ideally with a warm
177+
pnpm store so scaffold installs offline and fast. Rebuild only when deps
178+
change. Set the e2b template id via the `e2bTemplate` setting (falls back
179+
to `FASTCLAW_SANDBOX_IMAGE`).
180+
2. **Pull source at scaffold time (image stays stable).** Override
181+
`FASTCLAW_SHIPANY_SCAFFOLD` to `curl` a pinned tarball from object storage
182+
(R2/S3, via a short-lived presigned URL — no long-lived creds in a sandbox
183+
that runs LLM code) into `/workspace`, then `pnpm install --offline`
184+
against a warm store baked in the image. Decouples template content from
185+
the image; only dep changes need an image rebuild. Prefer this over
186+
`git clone` for private templates (no token to exfiltrate, pinned snapshot).
187+
3. **Upload from the fastclaw host.** When `FASTCLAW_SHIPANY_TEMPLATE_DIR`
188+
points at a checkout on the fastclaw server, the e2b path uploads it into
189+
the sandbox `/template` (`TemplateProvisioner.ProvisionDir`). Convenient
190+
for local e2b testing; costs a per-cold-sandbox upload.
191+
192+
**Multi-template, one image:** same-stack templates (e.g. several ShipAny
193+
variants) share ONE backend image and differ only by `ScaffoldCmd` / source —
194+
you do **not** need an e2b image per template. A genuinely different stack
195+
(Python vs Node) is the only reason to vary the image; `TemplateSpec.Image`
196+
pins a per-template image on the **docker** path (the e2b path uses the one
197+
pool image, so per-template e2b images would need a pool per-project override
198+
— not wired yet).
149199

150200
### Sandbox image requirements
151201

internal/agent/context.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -321,7 +321,11 @@ func (cb *ContextBuilder) BuildSystemPromptAs(chatterUID string, chatterMem *Mem
321321
loc := cb.chatterLocation(chatterUID)
322322
now := time.Now().In(loc)
323323
wd := now.Weekday().String()
324-
dateLine := fmt.Sprintf("Current date/time: %s (%s, %s — the chatter's local timezone). Use this — do NOT call `date` to learn what day it is.",
324+
dateLine := fmt.Sprintf("Current date/time: %s (%s, %s — the chatter's local timezone). This is NOW; do NOT call `date`. "+
325+
"Each past user message in the history is prefixed with its own send time in [brackets] (e.g. [2026-06-13 22:15 Fri]). "+
326+
"Reason about time from NOW and those prefixes: tell today apart from earlier days (never treat a past day's events as today's), "+
327+
"and before ANY time-of-day remark check NOW — e.g. don't say \"good night\" in the middle of the day. "+
328+
"If the chatter states a timezone or local time that disagrees with the above, call set_timezone to correct it.",
325329
now.Format("2006-01-02 15:04:05 -0700"), wd, now.Location().String())
326330

327331
switch mode {

internal/agent/loop.go

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1668,7 +1668,7 @@ func (a *Agent) handlePlanMode(ctx context.Context, msg bus.InboundMessage) stri
16681668
if catalog != "" {
16691669
messages = append(messages, provider.Message{Role: "system", Content: catalog})
16701670
}
1671-
messages = append(messages, sess.GetMessages()...)
1671+
messages = append(messages, a.withMessageTimestamps(sess.GetMessages())...)
16721672
if a.piiScrubEnabled {
16731673
messages = privacy.ScrubMessages(messages)
16741674
}
@@ -1905,7 +1905,7 @@ func (a *Agent) HandleMessage(ctx context.Context, msg bus.InboundMessage) strin
19051905
if reminder := renderChatbotPersistenceReminder(a.promptMode, a.displayName, chatterMem.LoadUserFile(), chatterMem.LoadMemory()); reminder != "" {
19061906
messages = append(messages, provider.Message{Role: "system", Content: reminder})
19071907
}
1908-
messages = append(messages, sessionMsgs...)
1908+
messages = append(messages, a.withMessageTimestamps(sessionMsgs)...)
19091909

19101910
toolDefs := a.registry.DefinitionsForMode(builtinAllowForMode(a.promptMode))
19111911

@@ -2601,7 +2601,7 @@ func (a *Agent) HandleMessageStream(ctx context.Context, msg bus.InboundMessage)
26012601
if reminder := renderChatbotPersistenceReminder(a.promptMode, a.displayName, chatterMem.LoadUserFile(), chatterMem.LoadMemory()); reminder != "" {
26022602
messages = append(messages, provider.Message{Role: "system", Content: reminder})
26032603
}
2604-
messages = append(messages, sessionMsgs...)
2604+
messages = append(messages, a.withMessageTimestamps(sessionMsgs)...)
26052605

26062606
toolDefs := a.registry.DefinitionsForMode(builtinAllowForMode(a.promptMode))
26072607

@@ -3063,6 +3063,30 @@ func (a *Agent) chatterLocation(chatterUID string) *time.Location {
30633063
return scope.LoadLocationOrLocal(tz)
30643064
}
30653065

3066+
// withMessageTimestamps returns a COPY of msgs where each user message is
3067+
// prefixed with its send time in the chatter's timezone, e.g.
3068+
// "[2026-06-13 22:15 Fri] …". This is what lets the model reason about
3069+
// time across a conversation — tell today from earlier days, and not say
3070+
// "good night" at midday. The originals are never mutated (the prefix is
3071+
// a read-time view for the LLM, not stored history), so the session store
3072+
// stays clean and the next turn doesn't double-prefix. The system prompt
3073+
// (context.go dateLine) tells the model what the bracketed prefix means.
3074+
func (a *Agent) withMessageTimestamps(msgs []provider.Message) []provider.Message {
3075+
if len(msgs) == 0 {
3076+
return msgs
3077+
}
3078+
loc := a.chatterLocation(a.registry.ChatterUserID())
3079+
out := make([]provider.Message, len(msgs))
3080+
for i, m := range msgs {
3081+
if m.Role == "user" && m.Timestamp > 0 && m.Content != "" {
3082+
t := time.UnixMilli(m.Timestamp).In(loc)
3083+
m.Content = "[" + t.Format("2006-01-02 15:04 Mon") + "] " + m.Content
3084+
}
3085+
out[i] = m
3086+
}
3087+
return out
3088+
}
3089+
30663090
// UpdateConfig updates the agent's runtime config (model, temperature, etc.)
30673091
func (a *Agent) UpdateConfig(rc config.ResolvedAgent) {
30683092
a.model = rc.Model

internal/agent/runtime_tools.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,15 @@ func (a *Agent) SetProjectRuntime(m *coderuntime.Manager) {
2929
func (a *Agent) registerProjectRuntimeTools() {
3030
reg := a.registry
3131

32+
// Surface the registered templates so the model can pick one by name
33+
// (and knows the default). Built at registration time — the manager has
34+
// every RegisterTemplate applied before SetProjectRuntime runs.
35+
tmplDesc := "Template ref to scaffold from on first boot (e.g. \"shipany-tanstack\"). Optional once a runtime already exists; the deployment's default is used when omitted."
36+
if refs := a.projectRuntime.Templates(); len(refs) > 0 {
37+
tmplDesc = fmt.Sprintf("Template ref to scaffold from on first boot. Available: %s — the first is the default, used when omitted. Optional once a runtime already exists.",
38+
strings.Join(refs, ", "))
39+
}
40+
3241
reg.Register(
3342
"start_app_preview",
3443
"PRIMARY tool for building a web app / website / landing page / dashboard — INCLUDING requests like 'use template X to make Y' or '用某模板做个…'. "+
@@ -40,7 +49,7 @@ func (a *Agent) registerProjectRuntimeTools() {
4049
"properties": map[string]any{
4150
"template": map[string]any{
4251
"type": "string",
43-
"description": "Template ref to scaffold from on first boot (e.g. \"shipany-tanstack\"). Optional once a runtime already exists; the deployment's default is used when omitted.",
52+
"description": tmplDesc,
4453
},
4554
},
4655
},

internal/agent/tools/file.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -863,6 +863,29 @@ func makeListDir(r *Registry) ToolFunc {
863863
// the path (absolute paths, `skills/...`, ad-hoc scripts, etc.). The
864864
// sandbox badge is emitted only for the executor-fallback path — store
865865
// hits intentionally don't badge, since they didn't run in the sandbox.
866+
// mirrorCodingWriteToSandbox pushes a coding-agent workspace write into the
867+
// live preview sandbox. Coding writes route to workspace.Store (host), which
868+
// docker bind-mounts into the dev-server container — but a remote backend
869+
// (E2B) shares no host mount, so without this the dev server never sees the
870+
// edit and HMR looks dead. Guarded on RemoteWorkspace, so it's a no-op for
871+
// docker (whose executor isn't remote). Best-effort: the user-visible write
872+
// already hit the store, so a mirror failure only degrades live-reload — we
873+
// log and move on. Destination is ABSOLUTE /workspace/<path> because the
874+
// dev server serves the sandbox /workspace root and envd resolves a bare
875+
// path against $HOME, not /workspace.
876+
func (r *Registry) mirrorCodingWriteToSandbox(ctx context.Context, path, content string) {
877+
if r.codingSubdir == "" || r.executor == nil {
878+
return
879+
}
880+
if _, ok := r.executor.(sandbox.RemoteWorkspace); !ok {
881+
return
882+
}
883+
dest := "/workspace/" + strings.TrimPrefix(filepath.ToSlash(filepath.Clean(path)), "/")
884+
if _, err := r.executor.WriteFile(ctx, dest, content); err != nil {
885+
slog.Warn("coding preview mirror to sandbox failed", "path", dest, "err", err)
886+
}
887+
}
888+
866889
func registerSandboxedFile(r *Registry, ex sandbox.Executor) {
867890
r.Register("read_file", "Read the contents of a file", map[string]interface{}{
868891
"type": "object",
@@ -995,6 +1018,7 @@ func registerSandboxedFile(r *Registry, ex sandbox.Executor) {
9951018
}
9961019
return "", fmt.Errorf("workspace put: %w", err)
9971020
}
1021+
r.mirrorCodingWriteToSandbox(ctx, args.Path, args.Content)
9981022
return fmt.Sprintf("Written %d bytes to %s", len(args.Content), args.Path), nil
9991023
case RouteSkillStore:
10001024
// Skill scaffolding (skill-creator's `skills/<name>/...`) lands
@@ -1175,6 +1199,7 @@ func registerSandboxedFile(r *Registry, ex sandbox.Executor) {
11751199
}
11761200
return "", fmt.Errorf("workspace put: %w", err)
11771201
}
1202+
r.mirrorCodingWriteToSandbox(ctx, args.Path, updated)
11781203
return fmt.Sprintf("Edited %s (%d replacement(s))", args.Path, count), nil
11791204
}
11801205
}

internal/gateway/gateway.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,13 @@ func (g *Gateway) Usage() usage.Meter { return g.usage }
226226
// Store returns the gateway's storage backend.
227227
func (g *Gateway) Store() store.Store { return g.store }
228228

229+
// SandboxPool returns the gateway's shared system sandbox pool (nil when
230+
// sandboxing is disabled). The project runtime borrows it so a dev-server
231+
// preview runs in the SAME executor the coding agent writes files to —
232+
// the only way edits reach the server on backends (E2B) without a shared
233+
// host mount.
234+
func (g *Gateway) SandboxPool() sandbox.ExecutorPool { return g.sandboxPool }
235+
229236
// TaskQueue returns the gateway's task queue.
230237
func (g *Gateway) TaskQueue() *taskqueue.Queue { return g.taskQueue }
231238

0 commit comments

Comments
 (0)