Skip to main content

Remote Compute

Per-worktree resource environments: how Daintree provisions, pauses, resumes, tears down and polls external compute as part of the worktree lifecycle, and what agents can and cannot do with it.

Reviewed

Remote Compute

A worktree can carry its own resource environment: an external compute target that Daintree provisions and tears down as a side effect of worktree actions.

It is a lifecycle-hook system, not a hosting product. You describe how to provision, pause, resume, tear down, check the status of and connect to something off-machine (a Docker stack, a cloud VM, an Akash lease, a remote sandbox), and Daintree runs those commands in response to worktree actions. The compute lifecycle is bound to the worktree lifecycle: creating a worktree on a non-local environment spins it up, deleting the worktree tears it down.

This sits above the project-level lifecycle scripts. Those cover local setup: installing dependencies, copying env files. Remote compute covers whatever lives off-machine.

Note
Remote compute is a per-worktree feature, so it needs a git-backed project. A folder opened without a repository has no worktrees to attach an environment to. See Worktrees need a repository.

Configuration

Resource configuration lives in .daintree/config.json. Daintree checks three locations in priority order and the first valid file wins: there is no merging between levels. A candidate is skipped when it is missing, unparseable, or fails schema validation, so a typo in the user-level file silently falls through to the repo-level one rather than breaking everything.

  1. ~/.daintree/projects/<sanitized-root>/config.json: user-level, outside the repo. For personal resources that shouldn't be committed.
  2. <worktreePath>/.daintree/config.json: worktree-level, scoped to one branch.
  3. <projectRootPath>/.daintree/config.json: repo-level, the usual home for team-shared environments.

Use the singular resource key for a single environment:

{
  "resource": {
    "provision": ["docker compose up -d"],
    "teardown": ["docker compose down -v"],
    "resume": ["docker compose start"],
    "pause": ["docker compose stop"],
    "status": "docker compose ps --format json | jq -s '{status: (if length > 0 then \"running\" else \"paused\" end)}'",
    "connect": "docker compose exec app bash",
    "timeouts": { "provision": 300, "teardown": 300 },
    "statusInterval": 60,
    "provider": "docker"
  }
}

Or the plural resources key for several named environments:

{
  "resources": {
    "docker-local": { "provision": [...], "teardown": [...] },
    "akash": { "provision": [...], "teardown": [...] }
  }
}

Resolution walks: the exact environmentId requested, then resources["default"], then the first entry in the map. A non-empty resources map short-circuits the singular resource key, so if both are present resource is never reached. Teardown resolves default or first-entry regardless of which environment id was asked for.

A second chain sits behind that one: when config.json yields nothing and the worktree's mode is non-local, Daintree falls back to resourceEnvironments in settings.json, at two locations only — user-level and <projectRoot>/.daintree/settings.json. That is what makes environments defined in the settings GUI work without a config file.

Lifecycle Phases

Six phases map to six actions in the action palette and the worktree card menu. The order is provision → (use) → pause ↔ resume → teardown, with status as a side-channel poll and connect as an interactive handoff.

PhaseActionDefault timeoutConfirmation
provisionworktree.resource.provision300sNone
teardownworktree.resource.teardown300sConfirm
resumeworktree.resource.resume120sNone
pauseworktree.resource.pause120sNone
statusworktree.resource.status120sNone
connectworktree.resource.connectn/a: opens a terminal panelNone

Two more actions, worktree.resource.config.get and worktree.resource.config.set, never touch config.json. They read and write resourceEnvironments in project settings — the fallback chain above, not the primary one. .set replaces the whole map rather than merging into it, and is classified as safe, so it runs without a confirmation step.

Timeouts are per-phase, set in the timeouts block. On timeout Daintree sends SIGTERM to the process group and escalates to SIGKILL after 5 seconds on Unix, or runs taskkill /F /T on Windows. Results carry a structured exit code and signal name, not just a boolean.

Actions for one worktree are serialized (a queue of concurrency 1, with an abort controller), so a provision and a teardown can't interleave against the same resource. Auto-poll runs are dropped while a manual action is queued.

Provision is idempotent

Provisioning something already ready, running, healthy or up is a no-op that reports Resource is already <status> rather than running the commands again — the word is whichever status the last poll returned, so you will see already ready, already up, already healthy. Provisioning something paused or stopped routes to resume instead. Any other state (unknown, error, never configured) falls through to a real provision.

Pause and resume

A successful resume or pause stamps resumedAt or pausedAt onto the worktree's resource status. Nothing else writes them: a status poll reports the current state but not when it last changed.

Status

The status command must print JSON with at least a status field, and that field must be a string. Two more are read if present: a string endpoint, and an object meta (an array is rejected).

Three things go wrong here, and they don't all land the same way:

  • Non-JSON output on a zero exit becomes unknown, a neutral state rather than a failure. A script that reports a live resource in a shape Daintree can't parse should not be called unhealthy.
  • A non-zero exit becomes unhealthy, whatever it printed.
  • JSON that parses but whose status is not a string also becomes unhealthy. {"status": 1} and {"state": "ok"} both land here: valid JSON, red light, nothing in the output explaining why. It is the easiest of the three to write by accident.

"stopped" is deprecated in favor of "paused" and is accepted only as a graceful fallback.

A status script for the Docker stack above, written to satisfy all three rules:

#!/usr/bin/env bash
set -euo pipefail
cd "$DAINTREE_WORKTREE_PATH"

if [ -z "$(docker compose ps -q --status running)" ]; then
  echo '{"status": "paused"}'
else
  echo '{"status": "ready", "endpoint": "http://localhost:3000"}'
fi

Point the config at it with "status": "./scripts/resource-status.sh". Reporting ready with an endpoint is also what triggers the wrapper described below.

Setting statusInterval (in seconds) pins the polling cadence and disables focus tiering. Leave it unset and polling is focus-tiered instead: roughly 30 seconds for the active worktree, 300 seconds in the background, with up to 10% jitter so a project full of environments doesn't poll in lockstep.

Teardown

A failed teardown does not block worktree deletion. If the remote is unreachable or already gone you should still be able to delete the worktree locally and clean up separately.

But a failed cloud teardown is billing-critical, so it raises its own toast ("Cloud resource may still be running") on a dedicated rate-limit bucket, so an unrelated burst of errors can't absorb it into a generic overflow row. Local cleanup failures deliberately do not get this treatment: the directory is about to be removed and there is nothing you would do differently.

Teardown is also the only phase whose full log is persisted — but only on the worktree-deletion path. Deleting a worktree runs the resource teardown and then the project's local teardown commands, and each writes its own scrubbed log to ~/.daintree/projects/<sanitized-root>/teardown-logs/<worktree>/<timestamp>.log, pruned to the ten most recent per worktree, with the path surfaced on the lifecycle status.

A teardown you invoke on its own — from the card menu, the action palette or the assistant — writes no log and surfaces no path. If you want the artifact, delete the worktree; if you only want the resource gone, expect the truncated tail and nothing behind it.

Captured output

All captured output is scrubbed for secrets. Notifications and status details carry the last 8192 bytes: a shared budget across the whole command array, not per command. When output is truncated the snippet is prefixed with a marker naming the byte count and where the rest went: ...(truncated — omitted N bytes; full log: <path>). Where no log was written — which, per above, is every teardown you invoke yourself — the marker ends full log unavailable instead.

The Shell Environment

Lifecycle commands run under a tight allowlist, not your interactive shell. Anything you rely on from .zshrc or .bashrc — aliases, tool paths, secrets — is not there.

What passes through:

  • PATH (a reduced system path when the parent has none), HOME, LANG, LANGUAGE
  • Any LC_* or DAINTREE_* variable from the parent process
  • On Windows, additionally SYSTEMROOT, USERPROFILE, TEMP, TMP and PATHEXT
  • Fixed values: TERM=dumb, CI=true, NONINTERACTIVE=1, GIT_TERMINAL_PROMPT=0, DEBIAN_FRONTEND=noninteractive

On top of the allowlist, Daintree injects:

  • DAINTREE_WORKTREE_PATH: absolute path to the worktree
  • DAINTREE_PROJECT_ROOT: absolute path to the project root
  • DAINTREE_WORKTREE_NAME: short name of the worktree
  • DAINTREE_BRANCH: branch name, when there is one
  • DAINTREE_RESOURCE_PROVIDER: the config's provider value
  • DAINTREE_RESOURCE_ENDPOINT: endpoint from the last status call
  • DAINTREE_RESOURCE_STATUS: raw output of the last status command

Adding your own variables

config.json has no env field, and there is no per-environment place to declare one. What fills the gap is Daintree's ordinary environment-variable settings: the global set and the project set are merged — project wins on a name collision — and passed into every lifecycle command, applied over the allowlist rather than under it. That is where an API token, a registry credential or HTTPS_PROXY belongs.

Set them in Settings › Environment to cover every project, or the project Variables tab for one. Environment variables across scopes covers how the three env-var surfaces differ.

The fixed values and the DAINTREE_* set are applied last, so they win over anything you define under the same name.

Template Variables

Commands support two placeholder syntaxes. Both resolve against the same table and lowercase the name; single-brace additionally matches hyphens. So {{branch}}, {{BRANCH}} and {branch} are the same variable, and {branch-slug} needs the single-brace form.

Single-brace substitution skips shell parameter expansion: ${FOO} is left alone.

VariableValue
branchBranch name on the worktree
branch-slugBranch name sanitized to lowercase [a-z0-9-]
worktree_pathAbsolute path to the worktree directory
worktree_nameShort name of the worktree
project_rootAbsolute path to the project root
endpointLast known endpoint from the status command
repo-nameRepository folder name
base-folderBase folder of the worktree path
parent-dirParent directory of the worktree

Every substitution is shell-escaped for you (single-quote wrapping on Unix, double-quote on Windows), except branch-slug, which is left unquoted when its charset really is [a-z0-9-] and escaped otherwise. Unresolved placeholders are left in place so a typo fails loudly instead of expanding to nothing. See Security > Lifecycle Command Injection for the threat model.

Named Environments

Named environments let one project support several targets: a cheap local Docker stack for day-to-day work, a remote lease for longer integration runs. Define them under resources in the config, or manage them in Project Settings > Worktree Setup > Resource Environments.

The settings UI gives you an environment selector, an icon picker (Server, Cloud, Container, CPU, Globe, Rocket, Database, Terminal, Box, Layers), per-environment command lists with numbered rows and up/down reorder buttons, an in-UI variable reference, and a Default Worktree Mode selector that sets what the New Worktree dialog pre-selects.

Note
The settings UI and the config.json schema diverge. The UI has icon, which the config file has no field for; the config file has timeouts, statusInterval and provider, which the UI cannot set. To pin a timeout or a poll interval, edit config.json directly.

Settings are stored as resourceEnvironments, activeResourceEnvironment and defaultWorktreeMode in either ~/.daintree/projects/<sanitized-root>/settings.json or <projectRoot>/.daintree/settings.json, matching the storage mode you chose for the project.

Provisioning a Worktree

There is no "Remote" mode. The New Worktree dialog renders an Environment radio group of Local plus one button per configured environment key, and the whole control is hidden when the project has none. Choosing anything other than Local provisions that environment after worktree setup completes. The helper text under the group says so.

Provisioning is also reachable without the dialog:

  • Provision from the worktree card menu or the action palette, for a worktree created as local.
  • worktree.createWithRecipe, which takes provisionResource and worktreeMode arguments.

Once a resource exists, the worktree card carries an environment popover: an icon in the row's status cluster (the environment's own icon when it has one) whose color tracks the resource state. Green for running, healthy, ready or up; amber for starting or provisioning; red for unhealthy, down, error or failed; neutral for paused, stopped, stopping or unknown. While a lifecycle action is in flight the icon pulses and the popover synthesizes the in-flight phase (provisioning, starting, paused, stopping) rather than showing the stale previous status.

Clicking it opens a detail panel with the status label, the endpoint, when it was last checked, the last command output, and a control to re-run the status check.

The daintree-remote wrapper

Once a status call returns ready with an endpoint and the config has a connect command, Daintree writes an executable wrapper to <worktree>/.daintree/daintree-remote (mode 0755). It forwards its arguments to the resolved connect command, which gives agents and scripts a stable invocation path that survives endpoint changes: they call ./.daintree/daintree-remote <command> rather than reconstructing an SSH or exec line.

A connect of:

"connect": "ssh -o StrictHostKeyChecking=accept-new deploy@{endpoint}"

turns ./.daintree/daintree-remote npm test into that SSH invocation, and keeps working when the endpoint moves and the next status poll rewrites the file.

Tip
Add .daintree/daintree-remote to your project's .gitignore. The rest of .daintree/ is meant to be committed — recipes especially — but this file is generated per worktree and carries a machine-specific endpoint.

What Agents Can Do With This

Remote compute is not an external tool surface. No worktree.resource.* action is exposed to external MCP clients, so a connected client cannot provision, pause, resume or tear down your infrastructure.

Reach is limited to the in-app assistant's tiers:

SurfaceWhat it can call
External MCP clientsNothing. worktree.createWithRecipe is on the external surface, so a client can ask for a worktree on a named environment, but it cannot drive the resource lifecycle afterwards.
Assistant workbench tierworktree.resource.status, read only.
Action tierAdds provision, pause and resume.
System tierAdds teardown.
No tierconnect: it opens an interactive terminal panel, which is a human gesture.

Failures are surfaced rather than swallowed. A worktree.resource action that fails raises a high-priority error notification carrying the message and a Copy details action, and then rethrows, so a provision that silently did nothing is no longer a possible outcome.

See MCP Server for the tier model and how to configure what a given client is allowed to reach.