Files
fleetd/docs/Worker-Startup-and-Trust.md
Dai Ha 926724a279 CB-112: workers inherit the primary's working directory (not $HOME)
A worker now opens the same directory the primary is in, unless told otherwise.
Resolution: explicit spawn cwd → per-profile config cwd → the primary's cwd
(auto-detected from the bridge_spawn caller's PID via lsof -d cwd) → the daemon's
cwd. Never $HOME.

Mechanism (found by live probe, corrects the earlier assumption): an agent.start
pane does NOT inherit its tab's or workspace's cwd — it starts in $HOME. herdr's
agent.start honours an (undocumented) cwd param, so the resolved cwd is threaded
onto agent.start {cwd} (both tab and pane placement), not tab.create.

Surfaces: bridge_spawn {cwd?} + auto-detect via ConnectionIdentity.resolve (peer
PID) + ProcessCwdLookup (lsof); REST POST /workers ?cwd= / body cwd; per-profile
'cwd:' config. Validated live: explicit cwd → worker rooted there; no cwd over
REST → daemon cwd, not $HOME. Also clears the ccs folder-trust prompt when the
project dir is already trusted (see docs/Worker-Startup-and-Trust.md).
2026-07-15 16:33:46 +02:00

7.1 KiB

Worker startup: working directory & the folder-trust prompt

When bridged spawns a worker, the worker CLI may show an interactive startup prompt before it is ready to accept a task — most importantly a "Do you trust the files in this folder?" dialog. An unattended worker parked on that prompt never becomes injectable: the status-gated injector waits for idle/blocked, the task is never delivered, and (worst case) a stray Enter answers the dialog wrong. How this is handled depends on two things:

  1. The worker's working directory — which folder the CLI is asked to trust.
  2. Which CLI launches the worker — each has its own first-run/trust behaviour.

This doc pins the current assumption (ccs is the launcher), how its trust prompt works, the rule that a worker inherits the primary's directory (never $HOME), and how other CLIs differ.

flowchart TD
    A["bridge_spawn / POST /workers"] --> B{"explicit cwd?<br/>(profile cwd or spawn arg)"}
    B -->|"yes — told otherwise"| C["use that cwd"]
    B -->|"no"| D{"caller PID resolvable?<br/>(MCP peer PID)"}
    D -->|"yes"| E["cwd = the primary's cwd<br/>lsof -a -p PID -d cwd"]
    D -->|"no (REST / off-host)"| F["cwd = bridged daemon cwd<br/>(never $HOME by assumption)"]
    C --> G["ensureWorkspace → tab.create → agent.start {cwd}"]
    E --> G
    F --> G
    G --> H{"does the CLI trust this folder?"}
    H -->|"yes"| I["worker reaches its prompt → injectable"]
    H -->|"no"| J["worker BLOCKS on the trust dialog<br/>never injectable"]
    classDef good fill:#2f855a,stroke:#22543d,color:#ffffff;
    classDef bad fill:#9b2c2c,stroke:#63171b,color:#ffffff;
    class I good
    class J bad

Figure 1 — spawn resolves a working directory, then the CLI's trust check gates readiness.

Working directory: inherit the primary's path

Rule: a worker opens the same directory the primary (main) session is working in, unless told otherwise. Never assume $HOME. If the primary is in /Users/you/LTMS/claude-bridge, its workers open there too — so delegated tasks share the same relative paths and the same (already-trusted) project folder.

The herdr seam. An agent.start pane does not inherit its tab's or workspace's cwd — it starts in $HOME unless told otherwise. herdr's agent.start honours an (undocumented) cwd param, verified live: setting it roots the worker process at that directory. So the worker's cwd is threaded onto agent.start {…, cwd}, not the placement step (workspace.create/tab.create cwd only affect the seed shell, which the bridge closes).

Resolution order (first match wins):

# Source When
1 Explicit cwd — a per-profile cwd: in config, or a spawn argument "told otherwise" — pin a fixed workdir
2 The primary's cwd, auto-detected from the bridge_spawn caller normal MCP spawn from the primary
3 The bridged daemon's own cwd REST spawn / off-host caller — never $HOME

The primary's cwd (source 2) is discoverable with no new plumbing: bridged already resolves the MCP caller's loopback peer PID for connection identity (ConnectionIdentity → LsofPeerPidLookup); the same PID yields its cwd via lsof -a -p <pid> -d cwd -Fn (the n… line). The primary maps to no worker pane (it is not a worker), but its PID and cwd are still readable.

sequenceDiagram
    participant P as "Primary (main)"
    participant B as "bridged"
    participant O as "OS (lsof)"
    participant H as "herdr"
    P->>B: "bridge_spawn {profile} (no cwd)"
    B->>O: "peer PID for this connection's port"
    O-->>B: "pid"
    B->>O: "cwd of pid (lsof -d cwd)"
    O-->>B: "/Users/you/LTMS/claude-bridge"
    B->>H: "tab.create (placement)"
    B->>H: "agent.start {argv, env, tab_id, cwd}"
    H-->>B: "worker in the primary's directory"

Figure 2 — a no-cwd spawn inherits the primary's directory from the caller's PID.

Status: implemented (CB-112). bridged threads the resolved cwd onto agent.start {cwd} (verified: the worker process is rooted there), keeping the single shared worker space. On an MCP bridge_spawn the primary's cwd is auto-detected from the caller's PID; over REST (no MCP caller) it is the explicit cwd param else the daemon's cwd. Both placements (tab and legacy pane) carry it, since it rides agent.start.

Assumed launcher: ccs (Claude Code)

For now the fleet assumes ccs (Claude Code under the hood) as the worker CLI — argv: ["ccs", "<profile>"]. Its startup gate is the folder-trust dialog.

How ccs/Claude Code decides whether to prompt

Trust is recorded per-directory, per config dir. Each ccs profile is an isolated instance with its own config dir (~/.ccs/instances/<profile>/) and its own .claude.json:

// ~/.ccs/instances/<profile>/.claude.json
"projects": {
  "/Users/you/LTMS/claude-bridge": { "hasTrustDialogAccepted": true },   // trusted → no prompt
  "/Users/you":                     { "hasTrustDialogAccepted": false }  // untrusted → prompts
}

The worker prompts iff its cwd is not marked hasTrustDialogAccepted: true for that instance. This is why the directory rule above matters: land workers in the primary's project folder and you grant trust once per profile, instead of scattering trust across $HOME and ad-hoc dirs.

Clearing the prompt (ranked)

  1. Inherit the primary's project dir (the rule above) and trust that folder once per profile.
  2. Pre-trust interactively: run ccs <profile> in the target folder and accept — persists hasTrustDialogAccepted: true for that path in the instance's .claude.json.
  3. Set the flag directly (scriptable, no interaction): set projects["<cwd>"].hasTrustDialogAccepted = true in ~/.ccs/instances/<profile>/.claude.json.
  4. Do not reach for --dangerously-skip-permissions — it disables all permission gating, not just this dialog, which defeats running off-subscription workers autonomously.

Other CLIs: different launchers, different prompts

ccs is the current assumption, not a hard dependency — a worker profile's argv can be any CLI. Each launcher has its own first-run/trust gate, so the "clear the prompt" step is CLI-specific and belongs with the profile, not hard-coded:

Launcher (argv) Startup gate How to clear it
ccs <profile> (Claude Code) Folder-trust dialog hasTrustDialogAccepted per project in the instance's .claude.json (above)
Other Claude-compatible runtimes via ccs (codex, gemini, cursor, …) Each has its own first-run / trust / login prompt Per-runtime; document per launcher as it is adopted
A bare command (bash -c …, mechanics probe) None n/a — used for non-interactive smoke tests

When adding a new launcher, capture its startup-prompt behaviour here (what blocks, and the non-interactive way to satisfy it) so a spawned worker of that kind reaches an injectable prompt unattended.

See also

  • docs/MCP-Contract.md — the tool surface (bridge_spawn, bridge_profiles, …).
  • wiki/2-Message-Server.md — the herdr agent.* / workspace.* schema (workspace.create {cwd}).