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.

Updated
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.
Warning
Creating a worktree copies the project root's .daintree/ directory into the new worktree, so candidate 2 normally exists as a copy of candidate 3 and shadows it from then on. Editing the repo-level file leaves every worktree that already exists on its stale copy. Edit the worktree's own copy, or delete it so the worktree falls through to the repo again.

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. These are file-based fallback locations; they are distinct from the current app-data project settings store.

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. For a full transcript without deleting the worktree, arrange logging in your teardown command; the standalone action returns only its bounded output tail.

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
Warning
Proxy variables and NODE_EXTRA_CA_CERTS are not on the allowlist. On a corporate network behind a proxy or a TLS-inspecting middlebox, a provision command that reaches the network will fail here even though the same command works in your terminal. See Adding your own variables for the fix.

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-folderRepository folder name (alias of repo-name)
parent-dirParent directory of the project root

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.

The GUI stores resourceEnvironments, activeResourceEnvironment and defaultWorktreeMode in the project’s machine-local app-data settings, under projects/<project-id>/settings.json. These fields are excluded from shared repository settings. The audited lifecycle loader’s fallback still reads the file locations above; use .daintree/config.json for a reproducible team configuration, and check actual provisioning output when relying on GUI-only commands.

Each command list is ordered, and the order is what runs

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 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 panel synthesizes the in-flight phase (provisioning, starting, paused, stopping) rather than showing the stale previous status.

The icon only becomes a button once there is something behind it: a reported status, an endpoint, a last output, a last-checked time, or a status command it can run on demand. Until then it is inert — a tooltip naming the mode, and nothing to click. When it is a button, it opens an environment popover headed by the worktree's mode, with the status label beside it, then the endpoint, the last command output, when it was last checked, and a Check Status control that re-runs the status command.

The shaded block is the status command's own stdout, printed verbatim — which is why the endpoint appears twice, once parsed out and once inside the JSON

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. This file is generated per worktree and carries a machine-specific endpoint. Review other .daintree/ content separately, particularly plugin storage and settings.

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 an external client cannot call those lifecycle controls directly. Worktree creation, deletion, and recipe execution can still run project-defined lifecycle commands as side effects; the external surface is not a guarantee that infrastructure remains untouched.

Reach is limited to the in-app tool sets:

SurfaceWhat it can call
External MCP clientsNo direct resource actions. Worktree creation, owned-worktree deletion and recipe execution can still trigger configured lifecycle commands.
Core tool setNo direct resource actions. Deleting a worktree the session created, which you confirm, runs its teardown as a side effect.
Full tool setworktree.resource.status, provision, pause, resume, and confirmed teardown. Tool set membership does not itself approve destructive commands.
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.