@clipboard-health/groundcrew 4.3.5 → 4.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -130,27 +130,69 @@ Resolution order: `GROUNDCREW_CONFIG` → cosmiconfig project-walk from cwd (any
130
130
  <details>
131
131
  <summary>Full configuration reference</summary>
132
132
 
133
- | Key | Default | What it does |
134
- | --------------------------------------- | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
135
- | `sources` | `[]` | Additional pluggable ticket sources, dispatched alongside the built-in Linear adapter. Built-in kinds: `shell`, `linear`. |
136
- | `git.remote` | `"origin"` | Remote used for `fetch` and as the worktree base ref. |
137
- | `git.defaultBranch` | `"main"` | Branch fetched from `git.remote` and used as the worktree base. |
138
- | `workspace.projectDir` | **required** | Parent dir for cloned repos and sibling ticket worktrees. |
139
- | `workspace.knownRepositories` | **required** | Repos searched for in ticket descriptions to infer where work belongs. A ticket labeled for groundcrew (`agent-*`) fails fast when no known repo appears; unlabeled tickets are ignored. |
140
- | `orchestrator.maximumInProgress` | `4` | Cap on in-progress tickets at once for this `crew` instance. |
141
- | `orchestrator.pollIntervalMilliseconds` | `120_000` | Poll interval in `--watch` mode. |
142
- | `orchestrator.sessionLimitPercentage` | `85` | Number in `(0, 100]`. A model whose codexbar session window exceeds this percentage is skipped that tick. |
143
- | `models.default` | `"claude"` | Tiebreak for `agent-any` resolution and fallback for explicit but unknown `agent-*` labels. Also used by `crew start <TICKET>` for unlabeled tickets. `crew run` ignores unlabeled tickets and does not apply this default. Must exist in `models.definitions`. |
144
- | `models.definitions` | `{ claude, codex }` | Agent definitions. Additive merge with shipped defaults. |
145
- | `models.definitions.<name>.cmd` | — | Shell command launched for the model. Runs in the worktree through the resolved `local.runner`. `{{worktree}}` is replaced before launch; `{{sandbox}}` expands to the sbx sandbox name under the sdx runner and an empty string otherwise. |
146
- | `models.definitions.<name>.color` | — | Color for the workspace status pill (cmux only; tmux silently drops it). |
147
- | `models.definitions.<name>.usage` | optional | If set, codexbar usage is fetched for this model and gated by `sessionLimitPercentage`. Falls back to default when unset, with gating enabled for known models. When `usage.codexbar.source` is omitted, groundcrew uses `oauth` for Codex/Claude on macOS, `auto` for other macOS providers, and `cli` elsewhere. Set to `{ disabled: true }` to disable usage gating. |
148
- | `models.definitions.<name>.sandbox` | optional | Docker Sandboxes binding for the model. Required at launch when `local.runner` resolves to `sdx`. Fields: `agent` (required sbx agent name) and `setupCommand` (override for the inside-sandbox setup script). Groundcrew assumes the `groundcrew-<agent>` sandbox already exists. |
149
- | `models.definitions.<name>.disabled` | optional | When set to exactly `true`, drops the named shipped default (`claude` or `codex`). Doctor skips probing it; `agent-<name>` labels fall back to `models.default` with a warning. |
150
- | `prompts.initial` | unattended template | First message sent to the agent. Placeholders: `{{ticket}}`, `{{worktree}}`, `{{title}}`, `{{description}}`. Override this from `crew.config.ts` for team-specific statuses, tools, plugins, or review loops. |
151
- | `workspaceKind` | `"auto"` | Terminal session manager. `"auto"` picks `cmux` when on PATH, else `tmux`. Set to `"cmux"` or `"tmux"` to fail loudly when the chosen backend is missing. |
152
- | `local.runner` | `"auto"` | Local isolation backend. `"auto"` `safehouse` on macOS, `sdx` on Linux/WSL. Explicit: `"safehouse"`, `"sdx"`, `"none"`. `"none"` is never picked implicitly. |
153
- | `logging.file` | XDG state path | Append-mode log file. `log()` / `logEvent()` tee here in addition to stdout. Defaults to `${XDG_STATE_HOME:-$HOME/.local/state}/groundcrew/groundcrew.log`. |
133
+ | Key | Default | What it does |
134
+ | ---------------------------------------- | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
135
+ | `sources` | `[]` | Additional pluggable ticket sources, dispatched alongside the built-in Linear adapter. Built-in kinds: `shell`, `linear`. |
136
+ | `git.remote` | `"origin"` | Remote used for `fetch` and as the worktree base ref. |
137
+ | `git.defaultBranch` | `"main"` | Branch fetched from `git.remote` and used as the worktree base. |
138
+ | `workspace.projectDir` | **required** | Parent dir for cloned repos and sibling ticket worktrees. |
139
+ | `workspace.knownRepositories` | **required** | Repos searched for in ticket descriptions to infer where work belongs. A ticket labeled for groundcrew (`agent-*`) fails fast when no known repo appears; unlabeled tickets are ignored. |
140
+ | `orchestrator.maximumInProgress` | `4` | Cap on in-progress tickets at once for this `crew` instance. |
141
+ | `orchestrator.pollIntervalMilliseconds` | `120_000` | Poll interval in `--watch` mode. |
142
+ | `orchestrator.sessionLimitPercentage` | `85` | Number in `(0, 100]`. A model whose codexbar session window exceeds this percentage is skipped that tick. |
143
+ | `models.default` | `"claude"` | Tiebreak for `agent-any` resolution and fallback for explicit but unknown `agent-*` labels. Also used by `crew start <TICKET>` for unlabeled tickets. `crew run` ignores unlabeled tickets and does not apply this default. Must exist in `models.definitions`. |
144
+ | `models.definitions` | `{ claude, codex }` | Agent definitions. Additive merge with shipped defaults. |
145
+ | `models.definitions.<name>.cmd` | — | Shell command launched for the model. Runs in the worktree through the resolved `local.runner`. `{{worktree}}` is replaced before launch; `{{sandbox}}` expands to the sbx sandbox name under the sdx runner and an empty string otherwise. |
146
+ | `models.definitions.<name>.color` | — | Color for the workspace status pill (cmux only; tmux silently drops it). |
147
+ | `models.definitions.<name>.usage` | optional | If set, codexbar usage is fetched for this model and gated by `sessionLimitPercentage`. Falls back to default when unset, with gating enabled for known models. When `usage.codexbar.source` is omitted, groundcrew uses `oauth` for Codex/Claude on macOS, `auto` for other macOS providers, and `cli` elsewhere. Set to `{ disabled: true }` to disable usage gating. |
148
+ | `models.definitions.<name>.sandbox` | optional | Docker Sandboxes binding for the model. Required at launch when `local.runner` resolves to `sdx`. Fields: `agent` (required sbx agent name) and `setupCommand` (override for the inside-sandbox setup script). Groundcrew assumes the `groundcrew-<agent>` sandbox already exists. |
149
+ | `models.definitions.<name>.preLaunch` | optional | Host-only shell snippet run **before** the agent exec and **outside** Safehouse/sdx. Exports survive into the launch shell; under the default `safehouse` runner they're only forwarded to the agent when listed via `preLaunchEnv` (recommended) or when `cmd` includes its own `safehouse --env-pass=NAMES`. `{{worktree}}` is substituted. A non-zero exit aborts launch. Not supported when `local.runner` resolves to `sdx` (v1). |
150
+ | `models.definitions.<name>.preLaunchEnv` | optional | Companion to `preLaunch`: list of env var names to append to groundcrew's `safehouse-clearance` `--env-pass=` flag, so `preLaunch` exports reach the agent **without** overriding `cmd` and losing the project's egress allowlist. Each entry must match `[A-Za-z_][A-Za-z0-9_]*`. Under `runner: "none"` exports already inherit and `preLaunchEnv` is a no-op. An empty array is a uniform no-op in every runner; a **non-empty** list is rejected when `cmd` already starts with `safehouse` (user owns env forwarding) or when `runner` resolves to `sdx`. |
151
+ | `models.definitions.<name>.disabled` | optional | When set to exactly `true`, drops the named shipped default (`claude` or `codex`). Doctor skips probing it; `agent-<name>` labels fall back to `models.default` with a warning. |
152
+ | `prompts.initial` | unattended template | First message sent to the agent. Placeholders: `{{ticket}}`, `{{worktree}}`, `{{title}}`, `{{description}}`. Override this from `crew.config.ts` for team-specific statuses, tools, plugins, or review loops. |
153
+ | `workspaceKind` | `"auto"` | Terminal session manager. `"auto"` picks `cmux` when on PATH, else `tmux`. Set to `"cmux"` or `"tmux"` to fail loudly when the chosen backend is missing. |
154
+ | `local.runner` | `"auto"` | Local isolation backend. `"auto"` → `safehouse` on macOS, `sdx` on Linux/WSL. Explicit: `"safehouse"`, `"sdx"`, `"none"`. `"none"` is never picked implicitly. |
155
+ | `logging.file` | XDG state path | Append-mode log file. `log()` / `logEvent()` tee here in addition to stdout. Defaults to `${XDG_STATE_HOME:-$HOME/.local/state}/groundcrew/groundcrew.log`. |
156
+
157
+ </details>
158
+
159
+ ### Per-session credentials (`preLaunch` + `preLaunchEnv`)
160
+
161
+ Build secrets shuttle build-time values _into_ setup. `preLaunch` does the opposite for the _agent_ phase: it runs a host-shell snippet **outside** Safehouse/sdx so the minting snippet never sees `NPM_TOKEN` / `BUF_TOKEN`, and its exports still land in the launch shell. Use this when the agent needs a short-lived credential that has to be minted from something the sandbox can't reach (e.g. an engineer CLI session in Keychain), and you don't want any of that source material — build-time or otherwise — in the minting snippet's environment or in the agent's process.
162
+
163
+ The "preLaunch never sees build secrets" contract is enforced differently per runner — same outcome, different mechanism:
164
+
165
+ - **`runner: "safehouse"` (default):** `preLaunch` runs immediately after `cd`, **before** `secrets.env` is sourced into the launch shell. `.groundcrew/setup.sh` then runs inside its own profile-neutral `safehouse-clearance` wrap with `--env-pass=NPM_TOKEN,BUF_TOKEN`; build secrets are `unset` on the host before the agent's Safehouse wrap is exec'd.
166
+ - **`runner: "none"`:** `secrets.env` is sourced first, `.groundcrew/setup.sh` runs on the host, build-secret names are `unset`, **then** `preLaunch` runs against a clean env, then the agent is exec'd.
167
+
168
+ Same security invariant, enforced via source-after-mint ordering under `safehouse` and post-cleanup ordering under `none`. If your snippet depends on a tool installed by `setup.sh`, prefer `runner: "none"` (preLaunch runs after setup) or inline the dependency; under `safehouse`, preLaunch runs before setup.
169
+
170
+ Under the default `safehouse` runner, the agent runs under a sanitized env allowlist — exports from `preLaunch` land in the launch shell but are stripped before reaching the agent unless they're forwarded. `preLaunchEnv` is the supported way to forward them: groundcrew passes the names via `--env-pass=` on the **agent** Safehouse wrap, so you keep the project's egress host allowlist (`clearance-allow-hosts`) without touching `cmd`. The names are scoped to the agent wrap only — the setup wrap and `.groundcrew/setup.sh` never see them, by design (setup runs profile-neutral and never carries the agent's grants).
171
+
172
+ ```typescript
173
+ models: {
174
+ definitions: {
175
+ claude: {
176
+ preLaunch: "SESSION_TOKEN=$(your-mint-command) && export SESSION_TOKEN",
177
+ preLaunchEnv: ["SESSION_TOKEN"],
178
+ },
179
+ },
180
+ },
181
+ ```
182
+
183
+ `&&` ensures `export` only runs when the mint succeeded; a failed mint propagates non-zero out of `preLaunch` and aborts launch before the agent starts. `{{worktree}}` is substituted the same way as in `cmd`. Under `runner: "none"`, exports flow through unchanged and `preLaunchEnv` is a no-op. A **non-empty** `preLaunchEnv` is not supported when `local.runner` resolves to `sdx` in v1 (sdx does not forward arbitrary host env into the sandbox); validated early in `setupWorkspace`. An empty `preLaunchEnv: []` is a uniform no-op in every runner — it forwards zero names, so the unsupported-runner guards do not fire.
184
+
185
+ <details>
186
+ <summary>Manual fallback when <code>cmd</code> brings its own <code>safehouse</code> wrap</summary>
187
+
188
+ If your `cmd` already starts with `safehouse` (because you need wrap flags groundcrew doesn't provide), groundcrew won't auto-compose `--env-pass=` for you and a **non-empty** `preLaunchEnv` is rejected at launch (an empty `[]` is accepted as a no-op). Add the names to your own `cmd` instead — note that this opts the model out of groundcrew's default `safehouse-clearance` wrap (egress allowlist), so re-supply `--append-profile` / `--env` yourself if you need it:
189
+
190
+ ```typescript
191
+ claude: {
192
+ preLaunch: "SESSION_TOKEN=$(your-mint-command) && export SESSION_TOKEN",
193
+ cmd: 'safehouse --env-pass=SESSION_TOKEN your-agent-cli',
194
+ },
195
+ ```
154
196
 
155
197
  </details>
156
198
 
@@ -460,7 +502,7 @@ If a `models.definitions.<name>.cmd` already starts with `safehouse`, groundcrew
460
502
  <details>
461
503
  <summary>Dead tmux windows vanish by default</summary>
462
504
 
463
- When a wrapped agent command fails (e.g. `safehouse-clearance` not found, `npm install` crash), the tmux window closes immediately and the error scrolls into the void. Set `GROUNDCREW_KEEP_DEAD_WINDOWS=1` in the env you launch `crew` from to flip the per-window `remain-on-exit` to `on`; the window stays open with the error visible. Close it manually with `tmux kill-window -t groundcrew:<ticket>` after diagnosis. tmux backend only.
505
+ When a wrapped agent command fails (e.g. `safehouse-clearance` not found, `npm install` crash), the tmux window closes immediately and the error scrolls into the void. Set `GROUNDCREW_KEEP_DEAD_WINDOWS=1` in the env you launch `crew` from to flip the per-window `remain-on-exit` to `on`; the window stays open with the error visible. `crew status` reports those kept windows as `exited` and keeps the tmux attach command visible so you can inspect scrollback before resuming or cleaning up. tmux backend only.
464
506
 
465
507
  </details>
466
508
 
@@ -61,6 +61,16 @@ export default {
61
61
  // cmd: "cursor-agent",
62
62
  // color: "#929292",
63
63
  // },
64
+ // // Optional: mint a short-lived credential outside Safehouse and
65
+ // // forward it into the agent. `preLaunch` runs in the launch shell
66
+ // // before the agent exec; `preLaunchEnv` lists the names to add to
67
+ // // groundcrew's `safehouse-clearance --env-pass=` flag so the wrap's
68
+ // // egress allowlist stays intact. Chain with `&&` so a failed mint
69
+ // // aborts launch before `export`.
70
+ // // claude: {
71
+ // // preLaunch: "SESSION_TOKEN=$(your-mint-command) && export SESSION_TOKEN",
72
+ // // preLaunchEnv: ["SESSION_TOKEN"],
73
+ // // },
64
74
  // // To run a model under the sdx (Docker Sandboxes) runner, bind it to
65
75
  // // an sbx agent. Required when `local.runner` resolves to `sdx`.
66
76
  // // claude: { sandbox: { agent: "claude" } },
@@ -1 +1 @@
1
- {"version":3,"file":"status.d.ts","sourceRoot":"","sources":["../../src/commands/status.ts"],"names":[],"mappings":"AAIA,OAAO,EAAc,KAAK,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAanE,MAAM,WAAW,aAAa;IAC5B,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AA6gBD,wBAAsB,MAAM,CAAC,MAAM,EAAE,cAAc,EAAE,OAAO,GAAE,aAAkB,GAAG,OAAO,CAAC,IAAI,CAAC,CAU/F;AAED,wBAAsB,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAI7D"}
1
+ {"version":3,"file":"status.d.ts","sourceRoot":"","sources":["../../src/commands/status.ts"],"names":[],"mappings":"AAIA,OAAO,EAAc,KAAK,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAanE,MAAM,WAAW,aAAa;IAC5B,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAkjBD,wBAAsB,MAAM,CAAC,MAAM,EAAE,cAAc,EAAE,OAAO,GAAE,aAAkB,GAAG,OAAO,CAAC,IAAI,CAAC,CAU/F;AAED,wBAAsB,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAI7D"}
@@ -68,8 +68,14 @@ function ticketWorkspaceText(probe, ticket) {
68
68
  if (probe.kind === "unavailable") {
69
69
  return workspaceProbeUnavailableLine(probe);
70
70
  }
71
+ if (isWorkspaceExited(probe, ticket)) {
72
+ return "exited";
73
+ }
71
74
  return probe.names.has(ticket) ? "live" : "not live";
72
75
  }
76
+ function isWorkspaceExited(probe, ticket) {
77
+ return probe.kind === "ok" && probe.exitedNames?.has(ticket) === true;
78
+ }
73
79
  function formatRunState(state) {
74
80
  if (state === undefined) {
75
81
  return "(none)";
@@ -116,6 +122,17 @@ function writeRecentLogs(config, ticket) {
116
122
  writeSection("Recent logs");
117
123
  writeOutput(logLines.join("\n"));
118
124
  }
125
+ async function exitedWorkspaceAccessHint(config, probe, ticket) {
126
+ if (!isWorkspaceExited(probe, ticket)) {
127
+ return undefined;
128
+ }
129
+ try {
130
+ return await withLogOutputSuppressed(async () => await workspaces.accessHint(config, ticket));
131
+ }
132
+ catch {
133
+ return undefined;
134
+ }
135
+ }
119
136
  function formatTicketLine(ticket, runState, sourceStatus) {
120
137
  const parts = [`ticket: ${ticket}`];
121
138
  if (sourceStatus.kind === "found") {
@@ -154,10 +171,14 @@ async function writeTicketStatus(config, rawTicket) {
154
171
  withLogOutputSuppressed(async () => await workspaces.probe(config)),
155
172
  readTicketSourceStatus(config, ticket),
156
173
  ]);
174
+ const accessHint = await exitedWorkspaceAccessHint(config, workspaceProbe, ticket);
157
175
  writeOutput(formatTicketLine(ticket, runState, sourceStatus));
158
176
  writeTicketTitle(runState, sourceStatus);
159
177
  writeOutput(`run: ${formatRunState(runState)}`);
160
178
  writeOutput(`workspace: ${ticketWorkspaceText(workspaceProbe, ticket)}`);
179
+ if (accessHint !== undefined) {
180
+ writeOutput(`attach: ${accessHint.command}`);
181
+ }
161
182
  await writeTicketWorktrees(config, ticket);
162
183
  writeRecentLogs(config, ticket);
163
184
  }
@@ -202,22 +223,26 @@ function formatDuration(ms) {
202
223
  /**
203
224
  * Combined human-readable state for the inventory row. Surfaces RunState
204
225
  * lifecycle and flags the two interesting disagreements with the workspace
205
- * probe `(session dead)` when we recorded a running dispatch but no
206
- * session is alive, and `(stray session)` when a session is alive without
207
- * any recorded dispatch. `probe.kind === "unavailable"` is treated as
208
- * "we don't know" and never produces a suffix. When the row is actively
209
- * running, appends the elapsed wall-clock time since dispatch.
226
+ * probe. A recorded running dispatch can have a missing or exited session;
227
+ * an idle row can have a stray live or exited session. `probe.kind ===
228
+ * "unavailable"` is treated as "we don't know" and never produces a suffix.
229
+ * When the row is actively running, appends the elapsed wall-clock time since
230
+ * dispatch.
210
231
  */
211
232
  function inventoryStateText(runState, probe, ticket, now) {
212
233
  const lifecycle = runState?.state ?? "idle";
213
234
  const duration = runStateDurationMs(runState, now);
214
235
  const flags = [];
215
236
  if (probe.kind === "ok") {
216
- const sessionLive = probe.names.has(ticket);
217
- if (lifecycle === "idle" && sessionLive) {
218
- flags.push("stray session");
237
+ const sessionPresent = probe.names.has(ticket);
238
+ const sessionExited = isWorkspaceExited(probe, ticket);
239
+ if (lifecycle === "idle" && sessionPresent) {
240
+ flags.push(sessionExited ? "stray exited session" : "stray session");
241
+ }
242
+ if ((lifecycle === "running" || lifecycle === "resumed") && sessionExited) {
243
+ flags.push("session exited");
219
244
  }
220
- if ((lifecycle === "running" || lifecycle === "resumed") && !sessionLive) {
245
+ else if ((lifecycle === "running" || lifecycle === "resumed") && !sessionPresent) {
221
246
  flags.push("session dead");
222
247
  }
223
248
  }
@@ -231,9 +256,11 @@ function inventoryStateText(runState, probe, ticket, now) {
231
256
  * probe disagree. Returned commands are safe defaults; the user is free to
232
257
  * ignore them and use `attach:` + `pr:` to investigate first.
233
258
  *
234
- * - Stray session (live session, no run-state record) `crew cleanup` to
235
- * tear down the orphaned worktree + close the session.
236
- * - Session dead (run-state says running/resumed, no live session)
259
+ * - Stray session (session present, no run-state record) -> `crew cleanup`
260
+ * to tear down the orphaned worktree and close the session.
261
+ * - Session exited (run-state says running/resumed, kept dead tmux window)
262
+ * -> attach first so the failed command remains available for inspection.
263
+ * - Session dead (run-state says running/resumed, no session present) ->
237
264
  * `crew resume` to bring the agent back; the worktree is preserved.
238
265
  *
239
266
  * No hint when the probe is unavailable (we genuinely don't know whether
@@ -244,11 +271,17 @@ function inventoryHint(runState, probe, ticket) {
244
271
  return undefined;
245
272
  }
246
273
  const lifecycle = runState?.state ?? "idle";
247
- const sessionLive = probe.names.has(ticket);
248
- if (lifecycle === "idle" && sessionLive) {
249
- return `run 'crew cleanup ${ticket}' to clear this stray session`;
274
+ const sessionPresent = probe.names.has(ticket);
275
+ const sessionExited = isWorkspaceExited(probe, ticket);
276
+ if (lifecycle === "idle" && sessionPresent) {
277
+ return sessionExited
278
+ ? `run 'crew cleanup ${ticket}' to clear this stray exited session`
279
+ : `run 'crew cleanup ${ticket}' to clear this stray session`;
280
+ }
281
+ if ((lifecycle === "running" || lifecycle === "resumed") && sessionExited) {
282
+ return `attach to inspect scrollback, then run 'crew resume ${ticket}'`;
250
283
  }
251
- if ((lifecycle === "running" || lifecycle === "resumed") && !sessionLive) {
284
+ if ((lifecycle === "running" || lifecycle === "resumed") && !sessionPresent) {
252
285
  return `run 'crew resume ${ticket}' to bring the session back`;
253
286
  }
254
287
  return undefined;
@@ -1,4 +1,4 @@
1
- import type { LocalRunner, ModelDefinition, ResolvedConfig } from "./config.ts";
1
+ import { type LocalRunner, type ModelDefinition, type ResolvedConfig } from "./config.ts";
2
2
  interface PreparedAgentLaunch {
3
3
  runner: LocalRunner;
4
4
  sandboxName: string | undefined;
@@ -1 +1 @@
1
- {"version":3,"file":"agentLaunch.d.ts","sourceRoot":"","sources":["../../src/lib/agentLaunch.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,WAAW,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAOhF,UAAU,mBAAmB;IAC3B,MAAM,EAAE,WAAW,CAAC;IACpB,WAAW,EAAE,MAAM,GAAG,SAAS,CAAC;CACjC;AAED,wBAAsB,kBAAkB,CAAC,KAAK,EAAE;IAC9C,MAAM,EAAE,cAAc,CAAC;IACvB,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,eAAe,CAAC;IAC5B,OAAO,EAAE,MAAM,GAAG,SAAS,CAAC;IAC5B,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB,GAAG,OAAO,CAAC,mBAAmB,CAAC,CA6B/B;AAED,wBAAsB,kBAAkB,CAAC,KAAK,EAAE;IAC9C,MAAM,EAAE,cAAc,CAAC;IACvB,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB,GAAG,OAAO,CAAC,IAAI,CAAC,CAUhB"}
1
+ {"version":3,"file":"agentLaunch.d.ts","sourceRoot":"","sources":["../../src/lib/agentLaunch.ts"],"names":[],"mappings":"AAEA,OAAO,EAEL,KAAK,WAAW,EAChB,KAAK,eAAe,EACpB,KAAK,cAAc,EACpB,MAAM,aAAa,CAAC;AAOrB,UAAU,mBAAmB;IAC3B,MAAM,EAAE,WAAW,CAAC;IACpB,WAAW,EAAE,MAAM,GAAG,SAAS,CAAC;CACjC;AAED,wBAAsB,kBAAkB,CAAC,KAAK,EAAE;IAC9C,MAAM,EAAE,cAAc,CAAC;IACvB,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,eAAe,CAAC;IAC5B,OAAO,EAAE,MAAM,GAAG,SAAS,CAAC;IAC5B,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB,GAAG,OAAO,CAAC,mBAAmB,CAAC,CAsD/B;AAED,wBAAsB,kBAAkB,CAAC,KAAK,EAAE;IAC9C,MAAM,EAAE,cAAc,CAAC;IACvB,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB,GAAG,OAAO,CAAC,IAAI,CAAC,CAUhB"}
@@ -1,4 +1,5 @@
1
1
  import { ensureClearance } from "@clipboard-health/clearance";
2
+ import { hasPreLaunchEnv, } from "./config.js";
2
3
  import { detectHostCapabilities } from "./host.js";
3
4
  import { assertLocalRunnerRequirements, resolveLocalRunner } from "./localRunner.js";
4
5
  import { sandboxNameFor } from "./sandboxName.js";
@@ -25,6 +26,23 @@ export async function prepareAgentLaunch(input) {
25
26
  if (runner === "sdx" && input.definition.sandbox === undefined) {
26
27
  throw new Error(`Local groundcrew ${input.purpose} with the sdx runner require a sandbox config on model '${input.model}'.`);
27
28
  }
29
+ if (runner === "sdx" && input.definition.preLaunch !== undefined) {
30
+ throw new Error(`Local groundcrew ${input.purpose} with the sdx runner do not support preLaunch on model '${input.model}'. ` +
31
+ "Use local.runner 'safehouse' or 'none', or remove preLaunch from the model.");
32
+ }
33
+ if (runner === "sdx" && hasPreLaunchEnv(input.definition)) {
34
+ throw new Error(`Local groundcrew ${input.purpose} with the sdx runner do not support preLaunchEnv on model '${input.model}'. ` +
35
+ "Use local.runner 'safehouse' or 'none', or remove preLaunchEnv from the model.");
36
+ }
37
+ // Mirror of buildLaunchCommand's defense — fail at config-resolution time so
38
+ // the operator sees the problem before the workspace is spawned, not deep in
39
+ // the launch shell. The buildLaunchCommand check stays as defense in depth.
40
+ if (runner === "safehouse" &&
41
+ hasPreLaunchEnv(input.definition) &&
42
+ /^safehouse(\s|$)/.test(input.definition.cmd)) {
43
+ throw new Error(`Local groundcrew ${input.purpose} on model '${input.model}' cannot inject preLaunchEnv when 'cmd' already starts with 'safehouse'. ` +
44
+ "Your cmd owns the wrap, so add the names to its own '--env-pass=' flag, or drop the 'safehouse' prefix from 'cmd' to let groundcrew compose the flag for you.");
45
+ }
28
46
  const sandboxName = runner === "sdx" && input.definition.sandbox !== undefined
29
47
  ? sandboxNameFor({ agent: input.definition.sandbox.agent })
30
48
  : undefined;
@@ -64,6 +64,30 @@ export interface ModelDefinition {
64
64
  * Groundcrew adds the Safehouse wrapper.
65
65
  */
66
66
  cmd: string;
67
+ /**
68
+ * Optional shell snippet run in the launch shell **before** the agent is
69
+ * exec'd and **outside** Safehouse/sdx. Use to mint short-lived credentials
70
+ * (e.g. `export SESSION_TOKEN=...`) that the wrapped `cmd` inherits via
71
+ * the process environment. `{{worktree}}` is replaced before launch.
72
+ * Failures abort launch (unlike deps setup, which logs and continues).
73
+ * Not supported for `local.runner` `sdx` in v1.
74
+ */
75
+ preLaunch?: string;
76
+ /**
77
+ * Optional list of env var names to forward from the launch shell into
78
+ * the agent under the safehouse runner. Companion to `preLaunch` —
79
+ * names exported by `preLaunch` go here so groundcrew appends them to
80
+ * the `safehouse-clearance` wrap's `--env-pass=` flag, preserving the
81
+ * project's egress allowlist (`clearance-allow-hosts`) without forcing
82
+ * the user to rewrite `cmd`. Under `local.runner: "none"` exports flow
83
+ * through unchanged, so `preLaunchEnv` is a no-op. An empty array is a
84
+ * uniform no-op in every runner (it forwards zero names, so the
85
+ * unsupported-runner guards do not fire). A non-empty list is rejected
86
+ * when `local.runner` resolves to `sdx` in v1, and when `cmd` already
87
+ * starts with `safehouse` (the user owns env forwarding in that case).
88
+ * Each name must match `[A-Za-z_][A-Za-z0-9_]*` (POSIX env var name).
89
+ */
90
+ preLaunchEnv?: string[];
67
91
  color: string;
68
92
  usage?: {
69
93
  codexbar: {
@@ -219,6 +243,17 @@ export interface ResolvedConfig {
219
243
  file: string;
220
244
  };
221
245
  }
246
+ /**
247
+ * Single source of truth for "is preLaunchEnv asking us to forward anything?"
248
+ *
249
+ * An empty array forwards zero names, so it is a uniform no-op in every
250
+ * runner. The unsupported-runner guards (sdx, safehouse-prefixed cmd) only
251
+ * fire when there is actually something to forward — rejecting `[]` only on
252
+ * those runners would make an empty list accepted under `safehouse`/`none`
253
+ * but fatal elsewhere, which is a worse asymmetry than what the helper
254
+ * collapses. Centralized so all four call sites stay in lockstep.
255
+ */
256
+ export declare function hasPreLaunchEnv(definition: Pick<ModelDefinition, "preLaunchEnv">): boolean;
222
257
  /**
223
258
  * True when `name` is a shipped default the user removed via `disabled: true`.
224
259
  * Derived from absence in `definitions` — that's the only path that removes a
@@ -1 +1 @@
1
- {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/lib/config.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/lib/config.ts"],"names":[],"mappings":"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"}
@@ -6,6 +6,7 @@ import { pathToFileURL } from "node:url";
6
6
  import { cosmiconfig } from "cosmiconfig";
7
7
  import { log, readEnvironmentVariable, setLogFile } from "./util.js";
8
8
  import { xdgConfigPath, xdgStatePath } from "./xdg.js";
9
+ import { BUILD_SECRET_NAMES } from "./buildSecrets.js";
9
10
  export { BUILD_SECRET_NAMES } from "./buildSecrets.js";
10
11
  /**
11
12
  * Reserved model name. A ticket labeled `agent-any` resolves at runtime
@@ -127,6 +128,40 @@ function normalizeOptionalString(value, path) {
127
128
  }
128
129
  return value.trim();
129
130
  }
131
+ const ENV_VAR_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
132
+ function validatePreLaunchEnv(modelName, value) {
133
+ const path = `models.definitions.${modelName}.preLaunchEnv`;
134
+ if (!Array.isArray(value)) {
135
+ fail(`${path} must be an array of env var names (got ${JSON.stringify(value)})`);
136
+ }
137
+ for (const [index, entry] of value.entries()) {
138
+ if (typeof entry !== "string" || !ENV_VAR_NAME_PATTERN.test(entry)) {
139
+ fail(`${path}[${index}] must be a POSIX env var name matching ${ENV_VAR_NAME_PATTERN.source} (got ${JSON.stringify(entry)})`);
140
+ }
141
+ // Build secrets are sourced into the host launch shell, forwarded only to
142
+ // the Safehouse *setup* wrap, and `unset` on the host before the agent
143
+ // wrap is exec'd. Listing one here would silently never reach the agent —
144
+ // fail loudly so the operator picks a different name (or removes the
145
+ // entry) instead of debugging a missing env var at runtime.
146
+ if (BUILD_SECRET_NAMES.includes(entry)) {
147
+ fail(`${path}[${index}] cannot be a BUILD_SECRET_NAMES entry (${BUILD_SECRET_NAMES.join(", ")}); ` +
148
+ "those are unset on the host before the agent wrap is exec'd, so forwarding them via --env-pass would be a no-op.");
149
+ }
150
+ }
151
+ }
152
+ /**
153
+ * Single source of truth for "is preLaunchEnv asking us to forward anything?"
154
+ *
155
+ * An empty array forwards zero names, so it is a uniform no-op in every
156
+ * runner. The unsupported-runner guards (sdx, safehouse-prefixed cmd) only
157
+ * fire when there is actually something to forward — rejecting `[]` only on
158
+ * those runners would make an empty list accepted under `safehouse`/`none`
159
+ * but fatal elsewhere, which is a worse asymmetry than what the helper
160
+ * collapses. Centralized so all four call sites stay in lockstep.
161
+ */
162
+ export function hasPreLaunchEnv(definition) {
163
+ return definition.preLaunchEnv !== undefined && definition.preLaunchEnv.length > 0;
164
+ }
130
165
  function isWorkspaceKindSetting(value) {
131
166
  return (typeof value === "string" && WORKSPACE_KIND_SETTINGS.includes(value));
132
167
  }
@@ -189,7 +224,7 @@ function failIfLegacyModelKeys(name, override) {
189
224
  if (override["disabled"] !== true) {
190
225
  fail(`models.definitions.${name}.disabled must be exactly \`true\` when set (got ${JSON.stringify(override["disabled"])})`);
191
226
  }
192
- const conflicting = ["cmd", "color", "usage", "sandbox"].filter((key) => Object.hasOwn(override, key));
227
+ const conflicting = ["cmd", "color", "usage", "sandbox", "preLaunch", "preLaunchEnv"].filter((key) => Object.hasOwn(override, key));
193
228
  if (conflicting.length > 0) {
194
229
  fail(`models.definitions.${name}: cannot combine \`disabled: true\` with other fields (${conflicting.join(", ")}). Either disable the model or override its fields, not both.`);
195
230
  }
@@ -208,6 +243,36 @@ export function isShippedDefaultDisabled(config, name) {
208
243
  function isUsageDisableSentinel(usage) {
209
244
  return isPlainObject(usage) && "disabled" in usage && usage.disabled;
210
245
  }
246
+ function buildOverrideCandidate(name, override, existing) {
247
+ const base = existing === undefined ? {} : cloneModelDefinition(existing);
248
+ // Per-key spread so overriding `cmd` alone preserves the default
249
+ // `color` / `usage`. Brand-new entries must supply both required fields.
250
+ const candidate = { ...base };
251
+ if (override.cmd !== undefined) {
252
+ candidate.cmd = override.cmd;
253
+ }
254
+ if (override.color !== undefined) {
255
+ candidate.color = override.color;
256
+ }
257
+ if (override.usage !== undefined) {
258
+ if (isUsageDisableSentinel(override.usage)) {
259
+ delete candidate.usage;
260
+ }
261
+ else {
262
+ candidate.usage = override.usage;
263
+ }
264
+ }
265
+ if (override.sandbox !== undefined) {
266
+ candidate.sandbox = normalizeSandbox(override.sandbox, `models.definitions.${name}.sandbox`);
267
+ }
268
+ if (override.preLaunch !== undefined) {
269
+ candidate.preLaunch = override.preLaunch;
270
+ }
271
+ if (override.preLaunchEnv !== undefined) {
272
+ candidate.preLaunchEnv = override.preLaunchEnv;
273
+ }
274
+ return candidate;
275
+ }
211
276
  function mergeDefinitions(user) {
212
277
  if (user !== undefined && !isPlainObject(user)) {
213
278
  fail("models.definitions must be an object");
@@ -229,28 +294,8 @@ function mergeDefinitions(user) {
229
294
  delete merged[name];
230
295
  continue;
231
296
  }
232
- const base = merged[name] === undefined ? {} : cloneModelDefinition(merged[name]);
233
- // Per-key spread so overriding `cmd` alone preserves the default
234
- // `color` / `usage`. Brand-new entries must supply both required fields.
235
- const candidate = { ...base };
236
- if (override.cmd !== undefined) {
237
- candidate.cmd = override.cmd;
238
- }
239
- if (override.color !== undefined) {
240
- candidate.color = override.color;
241
- }
242
- if (override.usage !== undefined) {
243
- if (isUsageDisableSentinel(override.usage)) {
244
- delete candidate.usage;
245
- }
246
- else {
247
- candidate.usage = override.usage;
248
- }
249
- }
250
- if (override.sandbox !== undefined) {
251
- candidate.sandbox = normalizeSandbox(override.sandbox, `models.definitions.${name}.sandbox`);
252
- }
253
- const { cmd, color, usage, sandbox } = candidate;
297
+ const candidate = buildOverrideCandidate(name, override, merged[name]);
298
+ const { cmd, color, usage, sandbox, preLaunch, preLaunchEnv } = candidate;
254
299
  if (typeof cmd !== "string" || cmd.length === 0) {
255
300
  fail(`models.definitions.${name}.cmd must be a non-empty string`);
256
301
  }
@@ -264,6 +309,12 @@ function mergeDefinitions(user) {
264
309
  if (sandbox !== undefined) {
265
310
  definition.sandbox = sandbox;
266
311
  }
312
+ if (preLaunch !== undefined) {
313
+ definition.preLaunch = preLaunch;
314
+ }
315
+ if (preLaunchEnv !== undefined) {
316
+ definition.preLaunchEnv = preLaunchEnv;
317
+ }
267
318
  merged[name] = definition;
268
319
  }
269
320
  return merged;
@@ -431,6 +482,15 @@ function validate(config) {
431
482
  if (definition.sandbox !== undefined) {
432
483
  requireString(definition.sandbox.agent, `models.definitions.${name}.sandbox.agent`);
433
484
  }
485
+ if (definition.preLaunch !== undefined) {
486
+ requireString(definition.preLaunch, `models.definitions.${name}.preLaunch`);
487
+ if (definition.preLaunch.trim().length === 0) {
488
+ fail(`models.definitions.${name}.preLaunch must contain non-whitespace characters`);
489
+ }
490
+ }
491
+ if (definition.preLaunchEnv !== undefined) {
492
+ validatePreLaunchEnv(name, definition.preLaunchEnv);
493
+ }
434
494
  }
435
495
  /* v8 ignore next 5 @preserve -- normalizeLocalRunner rejects invalid strings before validate() runs; this is a belt-and-suspenders guard */
436
496
  if (!LOCAL_RUNNER_SETTINGS.includes(config.local.runner)) {
@@ -1 +1 @@
1
- {"version":3,"file":"launchCommand.d.ts","sourceRoot":"","sources":["../../src/lib/launchCommand.ts"],"names":[],"mappings":"AAGA,OAAO,EAAsB,KAAK,WAAW,EAAE,KAAK,eAAe,EAAE,MAAM,aAAa,CAAC;AAGzF,OAAO,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAE9C;;;;;;;;;GASG;AACH,wBAAgB,6BAA6B,CAAC,OAAO,GAAE,MAAwB,GAAG,MAAM,CAcvF;AAID;;;GAGG;AACH,eAAO,MAAM,aAAa,mFACwD,CAAC;AAiHnF,UAAU,sBAAsB;IAC9B,UAAU,EAAE,eAAe,CAAC;IAC5B,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB;;;;;;OAMG;IACH,WAAW,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACjC;;;;OAIG;IACH,MAAM,EAAE,WAAW,CAAC;IACpB;;;;;OAKG;IACH,WAAW,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CAClC;AAED;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAAC,UAAU,EAAE,sBAAsB,GAAG,MAAM,CAQ7E"}
1
+ {"version":3,"file":"launchCommand.d.ts","sourceRoot":"","sources":["../../src/lib/launchCommand.ts"],"names":[],"mappings":"AAGA,OAAO,EAGL,KAAK,WAAW,EAChB,KAAK,eAAe,EACrB,MAAM,aAAa,CAAC;AAGrB,OAAO,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAE9C;;;;;;;;;GASG;AACH,wBAAgB,6BAA6B,CAAC,OAAO,GAAE,MAAwB,GAAG,MAAM,CAcvF;AAID;;;GAGG;AACH,eAAO,MAAM,aAAa,mFACwD,CAAC;AAyKnF,UAAU,sBAAsB;IAC9B,UAAU,EAAE,eAAe,CAAC;IAC5B,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB;;;;;;OAMG;IACH,WAAW,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACjC;;;;OAIG;IACH,MAAM,EAAE,WAAW,CAAC;IACpB;;;;;OAKG;IACH,WAAW,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CAClC;AAED;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAAC,UAAU,EAAE,sBAAsB,GAAG,MAAM,CA0B7E"}
@@ -1,6 +1,6 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { basename, dirname, resolve } from "node:path";
3
- import { BUILD_SECRET_NAMES } from "./config.js";
3
+ import { BUILD_SECRET_NAMES, hasPreLaunchEnv, } from "./config.js";
4
4
  import { shellSingleQuote } from "./shell.js";
5
5
  export { shellSingleQuote } from "./shell.js";
6
6
  /**
@@ -35,6 +35,9 @@ function renderAgentCommand(arguments_) {
35
35
  .replaceAll("{{worktree}}", shellSingleQuote(arguments_.worktreeDir))
36
36
  .replaceAll("{{sandbox}}", shellSingleQuote(arguments_.sandboxName));
37
37
  }
38
+ function renderPreLaunch(preLaunch, worktreeDir) {
39
+ return preLaunch.replaceAll("{{worktree}}", shellSingleQuote(worktreeDir));
40
+ }
38
41
  function setupWithStatusReporting(setupCommand) {
39
42
  return [
40
43
  setupCommand,
@@ -50,8 +53,45 @@ function setupWithStatusReporting(setupCommand) {
50
53
  function sourceSecretsLine(secretsFile) {
51
54
  return `if [ -f ${shellSingleQuote(secretsFile)} ]; then set -a && . ${shellSingleQuote(secretsFile)} && set +a; fi`;
52
55
  }
56
+ function unsetEnvironmentLine(names) {
57
+ return `unset ${[...new Set(names)].join(" ")}`;
58
+ }
53
59
  function unsetSecretsLine() {
54
- return `unset ${BUILD_SECRET_NAMES.join(" ")}`;
60
+ return unsetEnvironmentLine(BUILD_SECRET_NAMES);
61
+ }
62
+ function trapCleanupLine(promptDir) {
63
+ const cleanupCmd = `rm -rf ${shellSingleQuote(promptDir)}`;
64
+ return `trap ${shellSingleQuote(cleanupCmd)} EXIT`;
65
+ }
66
+ /**
67
+ * Shared head of every host-shell `&&` chain: arm the `EXIT` trap that wipes
68
+ * `promptDir` (must come before any link that can fail, including the `cd`),
69
+ * then `cd` into the worktree. Kept separate from secret sourcing so the
70
+ * safehouse path can splice `preLaunch` between the `cd` and the secrets
71
+ * source — preLaunch must never see build-time secrets in env.
72
+ */
73
+ function hostTrapAndCd(arguments_) {
74
+ return [trapCleanupLine(arguments_.promptDir), `cd ${shellSingleQuote(arguments_.worktreeDir)}`];
75
+ }
76
+ /**
77
+ * Optional source-of-secrets line. Returns `[]` when no `secretsFile` is
78
+ * staged so callers can splat the result into their chain unconditionally.
79
+ */
80
+ function hostSourceSecrets(secretsFile) {
81
+ return secretsFile === undefined ? [] : [sourceSecretsLine(secretsFile)];
82
+ }
83
+ /**
84
+ * Shared tail of every host-shell `&&` chain: optional `preLaunch`, then the
85
+ * staged prompt read, the explicit success-path `rm -rf` (the trap covers the
86
+ * failure path), and the final `exec` of whatever wraps (or is) the agent.
87
+ */
88
+ function preLaunchPromptAndExec(arguments_) {
89
+ const lines = [];
90
+ if (arguments_.definition.preLaunch !== undefined) {
91
+ lines.push(renderPreLaunch(arguments_.definition.preLaunch, arguments_.worktreeDir));
92
+ }
93
+ lines.push(`_p=$(cat ${shellSingleQuote(arguments_.promptFile)})`, `rm -rf ${shellSingleQuote(arguments_.promptDir)}`, arguments_.execLine);
94
+ return lines;
55
95
  }
56
96
  function tokenizeShellPrefix(command) {
57
97
  const tokens = [];
@@ -134,11 +174,23 @@ function safehouseProfileCommandName(agentCmd) {
134
174
  */
135
175
  export function buildLaunchCommand(arguments_) {
136
176
  if (arguments_.runner === "sdx") {
177
+ if (arguments_.definition.preLaunch !== undefined) {
178
+ throw new Error("preLaunch is not yet supported for runner='sdx'. Set local.runner to 'safehouse' or 'none', or open an issue for sdx support.");
179
+ }
180
+ if (hasPreLaunchEnv(arguments_.definition)) {
181
+ throw new Error("preLaunchEnv is not yet supported for runner='sdx'. Set local.runner to 'safehouse' or 'none', or open an issue for sdx support.");
182
+ }
137
183
  return buildSdxLaunchCommand(arguments_);
138
184
  }
139
185
  if (shouldWrapWithSafehouse(arguments_)) {
140
186
  return buildSafehouseLaunchCommand(arguments_);
141
187
  }
188
+ if (hasPreLaunchEnv(arguments_.definition) && arguments_.runner === "safehouse") {
189
+ // `runner === "safehouse"` but `cmd` already starts with `safehouse` — the
190
+ // user owns env forwarding in that case, so there's no wrap flag for us to
191
+ // inject into. Fail loudly instead of silently dropping the contract.
192
+ throw new Error("preLaunchEnv cannot be injected when `cmd` starts with `safehouse` — your cmd owns the wrap, so add the names to its own `--env-pass=` flag, or drop the `safehouse` prefix from `cmd` to let groundcrew compose the flag for you.");
193
+ }
142
194
  return buildUnwrappedHostLaunchCommand(arguments_);
143
195
  }
144
196
  /**
@@ -165,30 +217,50 @@ function buildUnwrappedHostLaunchCommand(arguments_) {
165
217
  worktreeDir: arguments_.worktreeDir,
166
218
  sandboxName: "",
167
219
  });
168
- const lines = [`cd ${shellSingleQuote(arguments_.worktreeDir)}`];
169
- if (arguments_.secretsFile !== undefined) {
170
- lines.push(sourceSecretsLine(arguments_.secretsFile));
171
- }
172
- lines.push(setupWithStatusReporting(SETUP_COMMAND));
220
+ const lines = [
221
+ ...hostTrapAndCd({ worktreeDir: arguments_.worktreeDir, promptDir }),
222
+ ...hostSourceSecrets(arguments_.secretsFile),
223
+ setupWithStatusReporting(SETUP_COMMAND),
224
+ ];
173
225
  if (arguments_.secretsFile !== undefined) {
174
226
  lines.push(unsetSecretsLine());
175
227
  }
176
- lines.push(`_p=$(cat ${shellSingleQuote(arguments_.promptFile)})`, `rm -rf ${shellSingleQuote(promptDir)}`, `exec ${agentCmd} "$_p"`);
228
+ lines.push(...preLaunchPromptAndExec({
229
+ definition: arguments_.definition,
230
+ worktreeDir: arguments_.worktreeDir,
231
+ promptFile: arguments_.promptFile,
232
+ promptDir,
233
+ execLine: `exec ${agentCmd} "$_p"`,
234
+ }));
177
235
  return lines.join(" && ");
178
236
  }
179
237
  /**
180
- * Safehouse launch. Setup runs *inside* a plain `safehouse-clearance` wrap
181
- * (mirroring the sdx runner) so the repo's `.groundcrew/setup.sh` and its
182
- * `npm install` are filesystem-isolated and egress-restricted without inheriting
183
- * agent credentials/state grants. The agent then runs in a second Safehouse wrap
184
- * through an agent-named shim so Safehouse can select only the agent profile.
238
+ * Safehouse launch. Two Safehouse wraps, by design:
239
+ *
240
+ * 1. **Setup wrap**: plain `safehouse-clearance ... sh -c '<setup>'`. Runs
241
+ * `.groundcrew/setup.sh --deps-only` filesystem-isolated and
242
+ * egress-restricted, **without** inheriting agent-profile grants.
243
+ * 2. **Agent wrap**: `safehouse-clearance "$shim" -c '<exec agent>' sh "$_p"`
244
+ * where `$shim` is a `mktemp`-d symlink to `/bin/sh` named after the
245
+ * agent (e.g. `claude`). Safehouse selects the matching agent profile
246
+ * from the wrapped command's basename (`claude-code.sb` etc.) without
247
+ * needing every agent profile enabled globally.
248
+ *
249
+ * Host ordering matters: when a `preLaunch` hook is present, inherited
250
+ * build-secret names and listed `preLaunchEnv` names are cleared before it runs.
251
+ * That keeps the credential-minting snippet from seeing build-time secrets in
252
+ * env — neither inherited values (the launch shell inherits groundcrew's env,
253
+ * from which `stageBuildSecrets` reads them) nor file-sourced values — and keeps
254
+ * stale same-named ambient credentials from being forwarded. `secrets.env` is
255
+ * then sourced into the host launch shell so Safehouse can forward build secrets
256
+ * into the **setup wrap** via `--env-pass=` (Safehouse's `--env=FILE` mode strips
257
+ * them otherwise). After setup returns, `BUILD_SECRET_NAMES` are `unset` again
258
+ * on the host so they cannot reach the agent wrap.
185
259
  *
186
- * Build secrets are sourced into the host launch shell so Safehouse can forward
187
- * them into the sandbox via `--env-pass` (Safehouse's `--env=FILE` mode otherwise
188
- * strips them); they're `unset` on the host after setup and not passed to the
189
- * agent wrap. The host keeps `cd`, the prompt read, and a temporary
190
- * command-named shim so Safehouse can select the intended agent profile while
191
- * the actual agent command remains `sh -c`.
260
+ * `--env-pass` composition is split per wrap (deliberate, post PR #128):
261
+ * - Setup wrap forwards build secrets only.
262
+ * - Agent wrap forwards `preLaunchEnv` names only. preLaunch credentials never
263
+ * reach the profile-neutral setup phase.
192
264
  */
193
265
  function buildSafehouseLaunchCommand(arguments_) {
194
266
  const promptDir = dirname(arguments_.promptFile);
@@ -200,23 +272,42 @@ function buildSafehouseLaunchCommand(arguments_) {
200
272
  });
201
273
  const setupCommand = setupWithStatusReporting(SETUP_COMMAND);
202
274
  const agentCommand = `exec ${agentCmd} "$@"`;
203
- // Trailing space keeps the flag and setup command separated; empty when no secrets.
204
- const envPassFlag = arguments_.secretsFile === undefined ? "" : `--env-pass=${BUILD_SECRET_NAMES.join(",")} `;
275
+ // Split --env-pass per wrap: the setup wrap only needs build secrets (so
276
+ // `npm install` etc. can authenticate); the agent wrap only needs the
277
+ // user's preLaunchEnv (build secrets are `unset` on the host between the
278
+ // two wraps, so forwarding them here would silently no-op). Keeps preLaunch
279
+ // credentials out of the profile-neutral setup phase — see PR #128.
280
+ // Trailing space keeps each flag separated from the next argv token.
281
+ const setupEnvPassFlag = arguments_.secretsFile === undefined ? "" : `--env-pass=${BUILD_SECRET_NAMES.join(",")} `;
282
+ const preLaunchEnvNames = arguments_.definition.preLaunchEnv ?? [];
283
+ const agentEnvPassFlag = preLaunchEnvNames.length === 0 ? "" : `--env-pass=${preLaunchEnvNames.join(",")} `;
205
284
  const safehouseWrapper = shellSingleQuote(SAFEHOUSE_CLEARANCE_WRAPPER_PATH);
206
- const lines = [`cd ${shellSingleQuote(arguments_.worktreeDir)}`];
207
- if (arguments_.secretsFile !== undefined) {
208
- lines.push(sourceSecretsLine(arguments_.secretsFile));
285
+ // Defensive shim+promptDir trap: by the time we arm it, `rm -rf <promptDir>`
286
+ // has already run (line below) so the promptDir wipe is a no-op on the happy
287
+ // path. Keeps the failure-window between shim creation and the explicit
288
+ // post-wrap cleanup covered for both targets without an unarmed window.
289
+ const shimAndPromptCleanup = `rm -rf "$_safehouse_shim_dir"; rm -rf ${shellSingleQuote(promptDir)}`;
290
+ const shimAndPromptTrap = `trap ${shellSingleQuote(shimAndPromptCleanup)} EXIT`;
291
+ const lines = hostTrapAndCd({ worktreeDir: arguments_.worktreeDir, promptDir });
292
+ if (arguments_.definition.preLaunch !== undefined) {
293
+ // Scrub inherited env before preLaunch runs. `stageBuildSecrets` copies
294
+ // build secrets out of `process.env`, and the launch shell inherits that
295
+ // env, so source-after-preLaunch is not enough by itself. Clearing
296
+ // preLaunchEnv names here also prevents stale same-named ambient values
297
+ // from being forwarded if the hook forgets to overwrite them.
298
+ lines.push(unsetEnvironmentLine([...BUILD_SECRET_NAMES, ...preLaunchEnvNames]));
299
+ lines.push(renderPreLaunch(arguments_.definition.preLaunch, arguments_.worktreeDir));
209
300
  }
210
- lines.push(`_p=$(cat ${shellSingleQuote(arguments_.promptFile)})`, `rm -rf ${shellSingleQuote(promptDir)}`, `${safehouseWrapper} ${envPassFlag}sh -c ${shellSingleQuote(setupCommand)}`);
301
+ lines.push(...hostSourceSecrets(arguments_.secretsFile), `_p=$(cat ${shellSingleQuote(arguments_.promptFile)})`, `rm -rf ${shellSingleQuote(promptDir)}`, `${safehouseWrapper} ${setupEnvPassFlag}sh -c ${shellSingleQuote(setupCommand)}`);
211
302
  if (arguments_.secretsFile !== undefined) {
212
303
  lines.push(unsetSecretsLine());
213
304
  }
214
- lines.push(`_safehouse_shim_dir=$(mktemp -d "\${TMPDIR:-/tmp}/groundcrew-safehouse-XXXXXX")`, `trap 'rm -rf "$_safehouse_shim_dir"' EXIT`, `_safehouse_shim="$_safehouse_shim_dir/${safehouseCommandName}"`, `ln -s /bin/sh "$_safehouse_shim"`,
305
+ lines.push(`_safehouse_shim_dir=$(mktemp -d "\${TMPDIR:-/tmp}/groundcrew-safehouse-XXXXXX")`, shimAndPromptTrap, `_safehouse_shim="$_safehouse_shim_dir/${safehouseCommandName}"`, `ln -s /bin/sh "$_safehouse_shim"`,
215
306
  // Safehouse selects an agent profile from the wrapped command's basename.
216
307
  // Running the real launch chain as `sh -c` would make it see `sh`, so use
217
308
  // an agent-named symlink to /bin/sh. This preserves per-agent profile
218
309
  // selection without enabling every agent profile.
219
- `${safehouseWrapper} "$_safehouse_shim" -c ${shellSingleQuote(agentCommand)} sh "$_p"; _safehouse_status=$?; rm -rf "$_safehouse_shim_dir"; trap - EXIT; exit "$_safehouse_status"`);
310
+ `{ ${safehouseWrapper} ${agentEnvPassFlag}"$_safehouse_shim" -c ${shellSingleQuote(agentCommand)} sh "$_p"; _safehouse_status=$?; rm -rf "$_safehouse_shim_dir"; trap - EXIT; exit "$_safehouse_status"; }`);
220
311
  return lines.join(" && ");
221
312
  }
222
313
  function buildSdxLaunchCommand(arguments_) {
@@ -244,7 +335,7 @@ function buildSdxLaunchCommand(arguments_) {
244
335
  const sbxEnvironmentFlags = arguments_.secretsFile === undefined
245
336
  ? ""
246
337
  : `${BUILD_SECRET_NAMES.map((name) => `-e ${name}`).join(" ")} `;
247
- const lines = [];
338
+ const lines = [trapCleanupLine(promptDir)];
248
339
  if (arguments_.secretsFile !== undefined) {
249
340
  lines.push(sourceSecretsLine(arguments_.secretsFile));
250
341
  }
@@ -1 +1 @@
1
- {"version":3,"file":"tmuxAdapter.d.ts","sourceRoot":"","sources":["../../src/lib/tmuxAdapter.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EACL,KAAK,OAAO,EAIb,MAAM,uBAAuB,CAAC;AAY/B,eAAO,MAAM,WAAW,EAAE,OAgEzB,CAAC"}
1
+ {"version":3,"file":"tmuxAdapter.d.ts","sourceRoot":"","sources":["../../src/lib/tmuxAdapter.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EACL,KAAK,OAAO,EAIb,MAAM,uBAAuB,CAAC;AAY/B,eAAO,MAAM,WAAW,EAAE,OA+DzB,CAAC"}
@@ -17,8 +17,7 @@ export const tmuxAdapter = {
17
17
  async open(spec, signal) {
18
18
  await ensureTmuxSession(signal);
19
19
  const target = tmuxTarget(spec.name);
20
- const keepDeadWindowsEnv = readEnvironmentVariable("GROUNDCREW_KEEP_DEAD_WINDOWS");
21
- const keepDeadWindows = keepDeadWindowsEnv !== undefined && keepDeadWindowsEnv.length > 0;
20
+ const keepDeadWindows = shouldKeepDeadWindows();
22
21
  await runWorkspaceCommand("tmux", [
23
22
  "new-window",
24
23
  "-d",
@@ -54,7 +53,7 @@ export const tmuxAdapter = {
54
53
  // oxlint-disable-next-line unicorn/no-useless-undefined -- undefined marks the workspace backend as unavailable.
55
54
  return undefined;
56
55
  }
57
- return parseTmuxWindows(probe.output);
56
+ return parseTmuxWindows(probe.output, { includeExited: shouldKeepDeadWindows() });
58
57
  },
59
58
  async close(name, signal) {
60
59
  try {
@@ -78,6 +77,10 @@ export const tmuxAdapter = {
78
77
  function tmuxTarget(name) {
79
78
  return `${TMUX_SESSION}:${name}`;
80
79
  }
80
+ function shouldKeepDeadWindows() {
81
+ const keepDeadWindowsEnv = readEnvironmentVariable("GROUNDCREW_KEEP_DEAD_WINDOWS");
82
+ return keepDeadWindowsEnv === "1";
83
+ }
81
84
  function isTmuxNotFoundError(error) {
82
85
  // runCommand surfaces the child's stderr in error.message, so the "no
83
86
  // server" / "missing session" / "can't find window" signatures are visible
@@ -130,7 +133,7 @@ async function ensureTmuxSession(signal) {
130
133
  }
131
134
  }
132
135
  }
133
- function parseTmuxWindows(output) {
136
+ function parseTmuxWindows(output, options = {}) {
134
137
  const items = [];
135
138
  for (const line of output.split("\n")) {
136
139
  if (line.length === 0) {
@@ -147,10 +150,11 @@ function parseTmuxWindows(output) {
147
150
  // pane_dead != 0 means the command exited and the window is a zombie
148
151
  // (only happens when remain-on-exit is on; defense in depth in case a
149
152
  // user-globally-set value beats our per-window override).
150
- if (deadFlag !== undefined && deadFlag !== "0") {
153
+ const isExited = deadFlag !== undefined && deadFlag !== "0";
154
+ if (isExited && options.includeExited !== true) {
151
155
  continue;
152
156
  }
153
- items.push({ name });
157
+ items.push(isExited ? { name, state: "exited" } : { name });
154
158
  }
155
159
  return items;
156
160
  }
@@ -10,6 +10,8 @@ export type WorkspaceKind = "cmux" | "tmux";
10
10
  export interface Workspace {
11
11
  /** Ticket id; the join key callers use. */
12
12
  name: string;
13
+ /** Omitted means live, for backends that do not expose an exited state. */
14
+ state?: "exited";
13
15
  }
14
16
  export interface WorkspaceStatus {
15
17
  text: string;
@@ -37,6 +39,7 @@ export interface OpenSpec {
37
39
  export type WorkspaceProbe = {
38
40
  kind: "ok";
39
41
  names: Set<string>;
42
+ exitedNames?: Set<string>;
40
43
  } | {
41
44
  kind: "unavailable";
42
45
  error?: unknown;
@@ -60,7 +63,7 @@ export type WorkspaceCloseResult = {
60
63
  export interface Adapter {
61
64
  open(spec: OpenSpec, signal?: AbortSignal): Promise<void>;
62
65
  /**
63
- * Live workspaces only. Returns:
66
+ * Known workspaces. Returns:
64
67
  * - `Workspace[]` when the adapter probe succeeded (may be empty).
65
68
  * - `undefined` when the adapter binary failed in a way that doesn't
66
69
  * distinguish "no live workspaces" from "couldn't ask".
@@ -1 +1 @@
1
- {"version":3,"file":"workspaceAdapter.d.ts","sourceRoot":"","sources":["../../src/lib/workspaceAdapter.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAIH,MAAM,MAAM,aAAa,GAAG,MAAM,GAAG,MAAM,CAAC;AAE5C,MAAM,WAAW,SAAS;IACxB,2CAA2C;IAC3C,IAAI,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,mBAAmB;IAClC,IAAI,EAAE,eAAe,CAAC;IACtB,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,QAAQ;IACvB,+CAA+C;IAC/C,IAAI,EAAE,MAAM,CAAC;IACb,+CAA+C;IAC/C,GAAG,EAAE,MAAM,CAAC;IACZ,qEAAqE;IACrE,OAAO,EAAE,MAAM,CAAC;IAChB,4EAA4E;IAC5E,MAAM,CAAC,EAAE,eAAe,CAAC;CAC1B;AAED;;;GAGG;AACH,MAAM,MAAM,cAAc,GACtB;IAAE,IAAI,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,GAAG,CAAC,MAAM,CAAC,CAAA;CAAE,GAClC;IAAE,IAAI,EAAE,aAAa,CAAC;IAAC,KAAK,CAAC,EAAE,OAAO,CAAA;CAAE,CAAC;AAE7C,MAAM,MAAM,wBAAwB,GAChC;IAAE,IAAI,EAAE,aAAa,CAAA;CAAE,GACvB;IAAE,IAAI,EAAE,SAAS,CAAA;CAAE,GACnB;IAAE,IAAI,EAAE,aAAa,CAAC;IAAC,KAAK,CAAC,EAAE,OAAO,CAAA;CAAE,CAAC;AAE7C,MAAM,MAAM,oBAAoB,GAC5B;IAAE,IAAI,EAAE,QAAQ,CAAA;CAAE,GAClB;IAAE,IAAI,EAAE,SAAS,CAAA;CAAE,GACnB;IAAE,IAAI,EAAE,aAAa,CAAC;IAAC,KAAK,CAAC,EAAE,OAAO,CAAA;CAAE,CAAC;AAE7C,MAAM,WAAW,OAAO;IACtB,IAAI,CAAC,IAAI,EAAE,QAAQ,EAAE,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC1D;;;;;OAKG;IACH,IAAI,CAAC,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,SAAS,EAAE,GAAG,SAAS,CAAC,CAAC;IAC7D,0DAA0D;IAC1D,KAAK,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,oBAAoB,CAAC,CAAC;IACzE;;;OAGG;IACH,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,mBAAmB,GAAG,SAAS,CAAC;CAC3D;AAED,wBAAsB,mBAAmB,CACvC,OAAO,EAAE,MAAM,EACf,UAAU,EAAE,SAAS,MAAM,EAAE,EAC7B,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,MAAM,CAAC,CAIjB;AAED,wBAAgB,eAAe,CAAC,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAE7D"}
1
+ {"version":3,"file":"workspaceAdapter.d.ts","sourceRoot":"","sources":["../../src/lib/workspaceAdapter.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAIH,MAAM,MAAM,aAAa,GAAG,MAAM,GAAG,MAAM,CAAC;AAE5C,MAAM,WAAW,SAAS;IACxB,2CAA2C;IAC3C,IAAI,EAAE,MAAM,CAAC;IACb,2EAA2E;IAC3E,KAAK,CAAC,EAAE,QAAQ,CAAC;CAClB;AAED,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,mBAAmB;IAClC,IAAI,EAAE,eAAe,CAAC;IACtB,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,QAAQ;IACvB,+CAA+C;IAC/C,IAAI,EAAE,MAAM,CAAC;IACb,+CAA+C;IAC/C,GAAG,EAAE,MAAM,CAAC;IACZ,qEAAqE;IACrE,OAAO,EAAE,MAAM,CAAC;IAChB,4EAA4E;IAC5E,MAAM,CAAC,EAAE,eAAe,CAAC;CAC1B;AAED;;;GAGG;AACH,MAAM,MAAM,cAAc,GACtB;IAAE,IAAI,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAAC,WAAW,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,CAAA;CAAE,GAC7D;IAAE,IAAI,EAAE,aAAa,CAAC;IAAC,KAAK,CAAC,EAAE,OAAO,CAAA;CAAE,CAAC;AAE7C,MAAM,MAAM,wBAAwB,GAChC;IAAE,IAAI,EAAE,aAAa,CAAA;CAAE,GACvB;IAAE,IAAI,EAAE,SAAS,CAAA;CAAE,GACnB;IAAE,IAAI,EAAE,aAAa,CAAC;IAAC,KAAK,CAAC,EAAE,OAAO,CAAA;CAAE,CAAC;AAE7C,MAAM,MAAM,oBAAoB,GAC5B;IAAE,IAAI,EAAE,QAAQ,CAAA;CAAE,GAClB;IAAE,IAAI,EAAE,SAAS,CAAA;CAAE,GACnB;IAAE,IAAI,EAAE,aAAa,CAAC;IAAC,KAAK,CAAC,EAAE,OAAO,CAAA;CAAE,CAAC;AAE7C,MAAM,WAAW,OAAO;IACtB,IAAI,CAAC,IAAI,EAAE,QAAQ,EAAE,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC1D;;;;;OAKG;IACH,IAAI,CAAC,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,SAAS,EAAE,GAAG,SAAS,CAAC,CAAC;IAC7D,0DAA0D;IAC1D,KAAK,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,oBAAoB,CAAC,CAAC;IACzE;;;OAGG;IACH,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,mBAAmB,GAAG,SAAS,CAAC;CAC3D;AAED,wBAAsB,mBAAmB,CACvC,OAAO,EAAE,MAAM,EACf,UAAU,EAAE,SAAS,MAAM,EAAE,EAC7B,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,MAAM,CAAC,CAIjB;AAED,wBAAgB,eAAe,CAAC,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAE7D"}
@@ -1 +1 @@
1
- {"version":3,"file":"workspaces.d.ts","sourceRoot":"","sources":["../../src/lib/workspaces.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAGH,OAAO,KAAK,EAAE,cAAc,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAC;AACxE,OAAO,EAA0B,KAAK,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAE1E,OAAO,EAGL,KAAK,QAAQ,EACb,KAAK,mBAAmB,EACxB,KAAK,oBAAoB,EACzB,KAAK,wBAAwB,EAC7B,KAAK,aAAa,EAClB,KAAK,cAAc,EACpB,MAAM,uBAAuB,CAAC;AAE/B,YAAY,EACV,QAAQ,EACR,SAAS,EACT,mBAAmB,EACnB,oBAAoB,EACpB,wBAAwB,EACxB,aAAa,EACb,cAAc,EACd,eAAe,GAChB,MAAM,uBAAuB,CAAC;AAE/B,MAAM,WAAW,mBAAmB;IAClC,SAAS,EAAE,oBAAoB,CAAC;IAChC,QAAQ,EAAE,aAAa,CAAC;IACxB,yDAAyD;IACzD,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,UAAU,gBAAgB;IACxB,MAAM,EAAE,cAAc,CAAC;IACvB,IAAI,EAAE,gBAAgB,CAAC;CACxB;AAED,wBAAgB,oBAAoB,CAAC,UAAU,EAAE,gBAAgB,GAAG,mBAAmB,CAUtF;AAsDD,iBAAe,eAAe,CAC5B,MAAM,EAAE,cAAc,EACtB,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,cAAc,CAAC,CAezB;AAED,iBAAe,sBAAsB,CACnC,MAAM,EAAE,cAAc,EACtB,IAAI,EAAE,MAAM,EACZ,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,mBAAmB,GAAG,SAAS,CAAC,CAG1C;AAED,iBAAe,kBAAkB,CAC/B,MAAM,EAAE,cAAc,EACtB,IAAI,EAAE,MAAM,EACZ,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,wBAAwB,CAAC,CAenC;AAED,eAAO,MAAM,UAAU;IACf,IAAI,SAAS,cAAc,QAAQ,QAAQ,WAAW,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC;IAIvF,KAAK;IACC,KAAK,SACD,cAAc,QAChB,MAAM,WACH,WAAW,GACnB,OAAO,CAAC,oBAAoB,CAAC;IAIhC,SAAS;IACT,UAAU;CACX,CAAC"}
1
+ {"version":3,"file":"workspaces.d.ts","sourceRoot":"","sources":["../../src/lib/workspaces.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAGH,OAAO,KAAK,EAAE,cAAc,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAC;AACxE,OAAO,EAA0B,KAAK,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAE1E,OAAO,EAGL,KAAK,QAAQ,EACb,KAAK,mBAAmB,EACxB,KAAK,oBAAoB,EACzB,KAAK,wBAAwB,EAC7B,KAAK,aAAa,EAClB,KAAK,cAAc,EACpB,MAAM,uBAAuB,CAAC;AAE/B,YAAY,EACV,QAAQ,EACR,SAAS,EACT,mBAAmB,EACnB,oBAAoB,EACpB,wBAAwB,EACxB,aAAa,EACb,cAAc,EACd,eAAe,GAChB,MAAM,uBAAuB,CAAC;AAE/B,MAAM,WAAW,mBAAmB;IAClC,SAAS,EAAE,oBAAoB,CAAC;IAChC,QAAQ,EAAE,aAAa,CAAC;IACxB,yDAAyD;IACzD,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,UAAU,gBAAgB;IACxB,MAAM,EAAE,cAAc,CAAC;IACvB,IAAI,EAAE,gBAAgB,CAAC;CACxB;AAED,wBAAgB,oBAAoB,CAAC,UAAU,EAAE,gBAAgB,GAAG,mBAAmB,CAUtF;AAsDD,iBAAe,eAAe,CAC5B,MAAM,EAAE,cAAc,EACtB,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,cAAc,CAAC,CAiBzB;AAED,iBAAe,sBAAsB,CACnC,MAAM,EAAE,cAAc,EACtB,IAAI,EAAE,MAAM,EACZ,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,mBAAmB,GAAG,SAAS,CAAC,CAG1C;AAED,iBAAe,kBAAkB,CAC/B,MAAM,EAAE,cAAc,EACtB,IAAI,EAAE,MAAM,EACZ,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,wBAAwB,CAAC,CAenC;AAED,eAAO,MAAM,UAAU;IACf,IAAI,SAAS,cAAc,QAAQ,QAAQ,WAAW,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC;IAIvF,KAAK;IACC,KAAK,SACD,cAAc,QAChB,MAAM,WACH,WAAW,GACnB,OAAO,CAAC,oBAAoB,CAAC;IAIhC,SAAS;IACT,UAAU;CACX,CAAC"}
@@ -74,7 +74,9 @@ async function probeWorkspaces(config, signal) {
74
74
  if (raw === undefined) {
75
75
  return { kind: "unavailable" };
76
76
  }
77
- return { kind: "ok", names: new Set(raw.map((ws) => ws.name)) };
77
+ const names = new Set(raw.map((ws) => ws.name));
78
+ const exitedNames = new Set(raw.filter((ws) => ws.state === "exited").map((ws) => ws.name));
79
+ return exitedNames.size === 0 ? { kind: "ok", names } : { kind: "ok", names, exitedNames };
78
80
  }
79
81
  async function accessHintForWorkspace(config, name, signal) {
80
82
  const adapter = await adapterFor(config, signal);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clipboard-health/groundcrew",
3
- "version": "4.3.5",
3
+ "version": "4.5.0",
4
4
  "description": "Linear-driven orchestrator that launches AI coding agents in git worktrees, with workspace lifecycle and usage tracking.",
5
5
  "keywords": [
6
6
  "agent",