A terminal AI coding agent β PHP port of charmbracelet/crush. It is a candy-core TEA program (a real Model/Program render loop with buffer-diffed output and Markdown-rendered replies) wrapped around a full agent engine: multiple LLM providers, model-driven tool calling gated by hooks, prompt-injecting skills, sub-agents, an MCP client/server, and SQLite session history.
ββ SugarCrush ββββββββββββββββββββββββββββββββββββββββ
β user> add a test for the Width helper β
β β
β assistant β
β I'll read the helper first, then write the test. β
β β Read src/Util/Width.php β
β β Edit tests/Util/WidthTest.php β
β Done β added 4 cases covering the clamp edges. β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β > run themβ β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Enter to send Β· Ctrl+P menu Β· /exit or ^C to quit
History: SugarCrush absorbed the former experimental
candy-crushport. There is now a singleSugarCraft\Crushlibrary.
composer install
./bin/sugarcrushWith no configuration the binary runs the offline EchoProvider through the full engine, so it launches with zero network and zero keys. Point it at a real model with environment variables:
# OpenAI
export SUGARCRUSH_PROVIDER=openai
export OPENAI_API_KEY=sk-...
export SUGARCRUSH_MODEL=gpt-4o # optional; provider default otherwise
./bin/sugarcrushSUGARCRUSH_PROVIDER accepts openai, anthropic, claude-code, sglang, bedrock, vertex, or custom. Each reads its own credentials from the environment (e.g. ANTHROPIC_API_KEY, AWS ambient creds for Bedrock, GOOGLE_APPLICATION_CREDENTIALS for Vertex). When a real provider is active, the binary wires the built-in coding tools (Bash/Read/Edit/Glob/Grep/WebFetch/Doctor/Skill) and the safety hooks automatically.
bin/sugarcrush parses argv before it constructs a Program, so the
scriptable paths never attach to the TTY or enter the alt-screen:
sugarcrush -p "explain the Width helper" # one prompt, print, exit
sugarcrush run "explain the Width helper" # same thing
sugarcrush -p "audit this" --output-format json # machine-readable envelope
sugarcrush --root /path/to/project # set the project root explicitly
sugarcrush --help # prints and exits (never opens the TUI)--root also accepts the first positional argument that looks like a path, so
sugarcrush ../other-project works. It is what the Bash/Read/Edit/Glob tools
are jailed to and where CLAUDE.md/AGENTS.md and .sugar-crush/skills are
looked for.
To avoid PHP SDKs entirely, set SUGARCRUSH_BACKEND_CMD to a command that reads JSON history on stdin and writes the reply to stdout:
export SUGARCRUSH_BACKEND_CMD=~/bin/anthropic.sh
./bin/sugarcrush#!/usr/bin/env bash
# ~/bin/anthropic.sh β keeps PHP network-dep-free, swap models by editing this file
payload=$(jq -nc --argjson h "$(cat)" '{model:"claude-opus-4-8", max_tokens:4096, messages:$h}')
curl -sN https://api.anthropic.com/v1/messages \
-H "x-api-key: $ANTHROPIC_API_KEY" -H "anthropic-version: 2023-06-01" \
-H "content-type: application/json" -d "$payload" | jq -r '.content[0].text'Three ways to get off the offline EchoProvider, from quickest to most permanent:
- One-off, this run only:
SUGARCRUSH_PROVIDER=dev-sglang ./bin/sugarcrushβdev-sglangis the project's own dev/test SGLang endpoint (declared in.sugar-crush/config.dev.json, checked into the repo), useful for trying a real (if smaller) model with zero API keys. - From inside the TUI: press Ctrl+P, choose Switch model, pick any provider from the list (built-in types plus every name declared in
.sugar-crush/config.dev.json, e.g.dev-sglang) β switches immediately, no restart. Switch theme works the same way for color themes. - Persisted across restarts: either of the above choices made via the palette is written to
~/.sugar-crush/config.jsonand read back on the next launch β so pickingdev-sglangonce via Ctrl+P means every future./bin/sugarcrush(with no env vars set at all) uses it automatically.$SUGARCRUSH_PROVIDER/$SUGARCRUSH_BACKEND_CMDstill take priority over the persisted choice when set, for scripting/CI overrides.
The interactive binary boots a pane shell (App) that hosts the chat
model, so the menu bar, pane strip, session tabs and the chat transcript are
all one candy-core Model tree β not two parallel UIs.
| Key | Does |
|---|---|
Enter |
Send |
Esc Esc |
Cancel the in-flight turn β press twice within 0.6s (a single Esc is a no-op, which is why the status bar reads Esc Esc to cancel while thinking) |
Esc |
Close the palette or the session picker |
Ctrl+C |
Quit |
Ctrl+P |
Command palette (fuzzy, grouped by category, biased by most-recently-used) |
Ctrl+O |
Expand/collapse the most recent tool call's output |
Ctrl+R |
Session picker (persisted across turns) |
Ctrl+A |
Same dispatch as typing /agents |
Ctrl+W / Alt+Backspace |
Delete the previous word |
Up (empty input) |
Recall the last message you sent |
Page Up / Page Down |
Scroll the transcript a screenful |
Tab |
Cycle panes |
Ctrl+Tab / Ctrl+Shift+Tab |
Cycle sessions |
F10 |
Open the menu bar |
y / n / a |
Answer a permission prompt: once / refuse / always |
Ctrl+P, Ctrl+O, Ctrl+A, Ctrl+W and Ctrl+C always belong to the chat
content model β the shell never claims them, in any pane, so hosting chat
inside the shell cannot silently steal a binding.
Mouse mode is on by default (SUGARCRUSH_DISABLE_MOUSE=1 turns it off). Zones
are registered during the render pass, so clicks land on what you see: wheel
scrolls the transcript, clicking a tool call expands/collapses it, clicking a
session tab or a pane label switches to it, clicking a palette/picker row
selects it, and clicking the menu bar opens a menu. Click-vs-drag is
discriminated so a text-selection drag does not fire the zone underneath it.
/agents /agent /bg /background /fork /branch /compact /mcp
/memory /rename /rewind /sessions /share /theme /workflow
/exit (/quit) β plus any file-based custom command found on disk.
Typing / opens a live popup of the matches.
New session, Switch model and Open docs are palette-only actions
(Ctrl+P) β they have no slash spelling, so CommandRegistry keeps them out
of the / popup.
/bg really does run the work: it dispatches onto a BackgroundSupervisor
that bin/sugarcrush constructs per launch, and the result comes back into
the transcript. /fork branches the current session.
Tool calls stream into the transcript as they happen β the forked child
emits lifecycle events rather than buffering until the turn ends β each with a
human-readable description and the command it actually ran, then a
runningβdone transition. Edit/Write results render a real unified diff. A
no-op edit reports as a no-op instead of success. Denied and interrupted calls
get their own visual state. Tool results that carry images are labelled and
rendered inline via candy-mosaic. Successful tool bodies are hidden by default
(Ctrl+O or a click opens them). Context usage shows as both a token count and
a percentage.
Sessions get a name automatically: after the first exchange a cheap
small-model backend (supplied separately from the conversation backend, so
naming never costs a second tool-capable agent turn) generates a title, which
is what /sessions, the tab strip and Ctrl+R list.
SugarCraft\Crush\Providers\ProviderInterface is the single LLM abstraction (capability introspection, batch + \Generator streaming, function calling, embeddings, per-model cost). Build one directly or from config via ProviderFactory (which resolves ${VAR} / ${VAR:-default} from the environment):
use SugarCraft\Crush\Providers\ProviderFactory;
$factory = new ProviderFactory();
$provider = $factory->create(['type' => 'openai', 'apiKey' => '${OPENAI_API_KEY}', 'model' => 'gpt-4o']);| Provider | Type key | Notes |
|---|---|---|
| OpenAI | openai |
openai-php/client; function calling, embeddings, cost table |
| Anthropic | anthropic |
real Messages API (/v1/messages, x-api-key) |
| Claude Code CLI | claude-code |
drives the claude binary headless; native cost; JSON schema |
| SGLang | sglang |
OpenAI-compatible self-hosted endpoints (Guzzle) |
| AWS Bedrock | bedrock |
Converse API via aws/aws-sdk-php; per-model pricing |
| GCP Vertex | vertex |
Anthropic-on-Vertex via an injectable predictor seam |
| Custom | custom |
any OpenAI-compatible HTTP endpoint |
| Echo | β | EchoProvider: offline, echoes the last turn; default + tests |
The sglang type accepts an optional toolCallParser key: 'openai' (the
default β read the server's parsed tool_calls[] array) or
'minimax-xml-fallback' (same, but when the array is absent, recover MiniMax's
raw <tool_call> XML out of the message content). Switch it only if your SGLang
deployment was launched without --tool-call-parser, which leaves the model's
tool-call XML unparsed in the content. Note this currently applies to the batch
complete() path only β the streaming path reassembles tool calls itself and
does not yet consult the setting.
EngineBackend bridges the chat-shell Backend seam to the engine. Each user turn runs a bounded agentic loop: call the provider through the Runtime, execute any tool calls through the hook gate, feed the results back, and repeat until the model answers without calling tools β or a maxSteps ceiling is hit.
use SugarCraft\Crush\Backend\EngineBackend;
use SugarCraft\Crush\Hooks\{HookManager, HookRegistry};
use SugarCraft\Crush\Tools\BuiltIn\{Bash, Read, Edit, Glob, Grep, WebFetch};
$hooks = new HookManager(new HookRegistry());
$hooks->registerBuiltIns(); // audit + confirm-rm + protect-files
$backend = (EngineBackend::new($provider, 'gpt-4o'))
->withTools([new Bash(), new Read(), new Edit(), new Glob(), new Grep(), new WebFetch()])
->withHooks($hooks);
(new Program(new Chat(backend: $backend)))->run();- Tools β
Tools\BuiltIn\*:Bash,Read,Edit,Glob,Grep,WebFetch,Doctor(a capability probe the model can call to report what this build/deployment actually supports), andSkill(level 2 of the progressive-disclosure design below). ImplementTools\Toolfor your own. - Hooks β
Hooks\*: pre/post-tool-use guards (allow / deny / modify the input). Built-ins:AuditHook,ConfirmRemoveHook,ProtectFilesHook. YAML config and externalScriptHooksupported. - Permission modes β
Permissions\*:PermissionGateenforces one of sixPermissionModes (default,accept-edits,plan,auto,dont-ask,bypass-permissions) per tool call, with a mode-independent rm-rf circuit breaker and a fail-closedautoclassifier when noSafetyClassifieris configured. - Skills β
Skills\*: frontmatterSKILL.mdfiles inject prompt context, matched by keyword/path. Discovered from built-ins (src/Skills/BuiltIn/),~/.sugar-crush/skills, and<project>/.sugar-crush/skills(project wins). Ships 12 built-ins spanning language/framework conventions (php-best-practices,laravel-best-practices,symfony-best-practices), workflow (testing-strategies,api-design,explore-codebase,worktree-workflow,mcp-authoring,matchups-sync) and the original four (security-audit,phpunit-master,composer-wizard).disable-model-invocation,user-invocable, andcontext: forkfrontmatter flags are enforced, not decorative β a fork-context skill runs throughAgentWorkerPoolas an isolated sub-agent. Loading is progressive: the system prompt carries only each skill's name + description, and the model pulls the fullSKILL.mdbody through theSkilltool when it decides one is relevant. Path-scoped skills self-announce β the first timeRead/Edit/Globtouches a file a skill'spaths:covers, that skill is surfaced (once per session, via one shared announce-set across the three tools). Skills authored for other CLIs (Claude Code, opencode) are imported rather than ignored, and the picker shows a provenance badge for where each one came from. - Agents β
Agents\*: 6 sub-agent presets (coder/reviewer/debugger/architect/tester/devops) with their own model, tools, skills, and a streaming lifecycle, dispatched throughAgentWorkerPool(pcntl_fork-based, with a synchronous fallback + warning whenpcntlis unavailable). - Teams & worktrees β
Agents\{Team,TeamManager,Teammate,TaskList,Mailbox}: a lead agent spawns a capped team of teammates that atomically claimTaskListtasks (SQLiteflock-backed, contention-tested) and exchange append-only JSON-lines mailbox messages.Agents\{WorktreeConfig,WorktreeManager,PathJail}give each teammate an isolated git worktree (.worktreeinclude-aware, swept for staleness) sandboxed by a path jail. - Workflows β
Workflows\*:WorkflowBuilder/WorkflowRegistry/WorkflowEnginerun multi-stage agent pipelines β sequentialstage(), fan-outparallel(), chainedpipeline(), and task-then-verifierwithVerification()β defined as PHP DSL files or YAML (WorkflowRegistry::loadYaml()). SIGINT/SIGTERM duringrun()captures a real pause file for later resumption at stage granularity. Seeexamples/workflows/lint-then-fix.yamlfor a runnable YAML example andworkflows/deep-research.phpfor the PHP DSL form. - MCP β
MCP\*: multi-server client (stdio + HTTP,.mcp.json,${VAR}interpolation) and stdio/HTTP servers to host your own tools. Per-agent-presetmcpServersallowlists are enforced byMcpClientagainstMcpRouter, not just decorative config. - Sessions β
Session\SessionStore: SQLite (WAL) persistence of sessions/messages/tool-calls with FK-enforced cascade and age-based pruning. - Tokens & export β
Util\TokenTracker(token + cost accumulation) andUtil\Exporter(Markdown / JSON / text transcripts). - Messages β typed
Messages\{System,User,Assistant,ToolResult}Message;UserMessagecarries file/image attachments;AssistantMessagecarries tool calls + reasoning. - Context files β
CLAUDE.md/AGENTS.mdat the project root are loaded into the system prompt, with@importexpansion (cycle- and traversal-guarded, and de-duplicated so an imported doc is not injected twice).Forcedinstructions come from user config. AnEnvironmentBlock(cwd, platform, git state, date) is prepended so the model is not guessing at its surroundings. - Permission prompts β a blocking request/reply flow (
HookResult::ask()βPermissionRequestMsg/PermissionReplyMsg) rendered as a Veil modal over the transcript; the answer settles the paused tool call rather than being advisory.
SugarCrush keeps the proven sugar-crush chassis (the Chat candy-core Model, buffer-diff Renderer) and runs the ported engine behind it. The interactive binary boots the pane shell that hosts that chassis:
bin/sugarcrush
ββ Bootstrap::app()
ββ Program β App (root candy-core Model: menu bar, pane focus, session tabs)
ββ Tui\Renderer β ChatPane ββ
β ββ Renderer (the live buffer-diff chat renderer)
ββ Chat (Model: input, scrollback, inFlight gate, permissions, zones)
ββ Backend ββ EchoBackend / CommandBackend (simple)
ββ EngineBackend (agent loop, emits tool-lifecycle events)
ββ Runtime β ProviderInterface (+ Tools Β· Hooks Β· Skills via App)
App plays two roles that are easy to confuse: it is the engine's state object (Runtime::run(App $app, β¦) and EngineBackend both take it, carrying tools/hooks/skills) and the root TUI Model the pane shell renders. Chat is untouched by the shell β it is still a standalone Model you can run directly with new Program(new Chat(...)).
The chassis speaks the root Message value object; the engine speaks the typed Messages\* hierarchy; EngineBackend converts at the seam.
Things that are genuinely not finished, stated plainly rather than left for you to discover:
SglangProvider'stoolCallParserapplies to the batchcomplete()path only. The streaming path reassembles tool calls itself and does not consult the setting.- Five shell commands are still inert:
GroupInputCmd,CancelAgentCmd,ResumeAgentCmd,StopAllAgentsCmd,QuitAgentViewCmd. The first has no counterpart in the live app; the agent four would need to reach into a worker pool the shell does not hold. Their pane/selection half is applied β only the action half is missing. - Workflow resume granularity is per whole stage. An interrupted parallel sub-stage cannot be resumed with partial credit.
pcntlis required for real parallelism. Without itAgentWorkerPoolfalls back to sequential execution and logs a one-time visible warning rather than pretending to fan out.- Providers are unit-tested against mocked transports. No test in this suite makes a live API call, so wire-format drift at a real endpoint is caught by
/doctorand by using it, not by CI. - The
Doctortool reports capabilities, it does not repair them.
use SugarCraft\Crush\Providers\{ProviderInterface, CompleteRequest, CompleteResponse, EmbeddingsRequest, EmbeddingsResponse};
final class MyProvider implements ProviderInterface
{
public function name(): string { return 'mine'; }
public function supportsStreaming(): bool { return false; }
public function supportsFunctionCalling(): bool { return true; }
public function supportsVision(): bool { return false; }
public function supportsJsonSchema(): bool { return false; }
public function contextWindow(): int { return 128_000; }
public function costPer1kTokens(string $model, string $direction): float { return 0.0; }
public function complete(CompleteRequest $r): CompleteResponse { /* ... */ }
public function completeStream(CompleteRequest $r): \Generator { /* yield CompleteResponse chunks */ }
public function embeddings(EmbeddingsRequest $r): EmbeddingsResponse { /* ... */ }
}cd sugar-crush && composer install && vendor/bin/phpunit4,337 tests / 12,587 assertions (0 failures, 0 errors). Coverage spans every subsystem: typed messages + attachments, the 6 built-in tools, all 7 providers (unit-tested with mocked transports β no live calls), the hook framework, permission-mode gating (incl. pcntl_fork concurrency stress tests for atomic task claiming), skills discovery + flag enforcement, sub-agents/teams/worktrees, workflow execution (sequential/parallel/pipeline/verification, PHP + YAML loading), the MCP client/servers (incl. per-agent routing enforcement), the SQLite store, token tracking, export, the TUI components, the Runtime orchestration (streaming accumulation, tool-result correlation, MODIFY hooks), the shell-out CommandBackend / StreamingCommandBackend, and the EngineBackend agentic loop (incl. the maxSteps guard).
A dedicated tests/Integration/ tier asserts reachability rather than behaviour: that the session store, session tabs, background sessions, the skills subsystem, mouse mode, the environment block and root context-file loading are actually reached from bin/sugarcrush β Bootstrap::app(), not merely implemented somewhere in src/. That tier exists because the audit recorded in the monorepo root's crush_code_update.md found well-tested subsystems that no real run could ever touch.
See CHANGELOG.md for how the suite got here.
