agent-relay-server 0.17.0 → 0.18.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/src/security.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  import { AUTH_TOKEN, CORS_ORIGINS, getIntegrationTokens, type IntegrationTokenConfig } from "./config";
2
2
  import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
3
3
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
- import { dirname, isAbsolute, join, relative, resolve } from "node:path";
4
+ import { dirname, join, resolve } from "node:path";
5
5
  import { getDb } from "./db";
6
+ import { isPathWithinBase } from "./utils";
6
7
  import type { ComponentToken, TokenConstraints } from "./types";
7
8
 
8
9
  const LOOPBACK_HOSTS = new Set(["127.0.0.1", "::1", "localhost"]);
@@ -427,15 +428,10 @@ function prefixAllows(allowed: string[] | undefined, value: string): boolean {
427
428
 
428
429
  function cwdAllows(constraints: TokenConstraints, cwd: string): boolean {
429
430
  if (constraints.cwd && resolve(cwd) !== resolve(constraints.cwd)) return false;
430
- if (constraints.cwdPrefixes?.length && !constraints.cwdPrefixes.some((prefix) => pathWithin(resolve(cwd), resolve(prefix)))) return false;
431
+ if (constraints.cwdPrefixes?.length && !constraints.cwdPrefixes.some((prefix) => isPathWithinBase(cwd, prefix))) return false;
431
432
  return true;
432
433
  }
433
434
 
434
- function pathWithin(path: string, baseDir: string): boolean {
435
- const rel = relative(baseDir, path);
436
- return rel === "" || (!!rel && !rel.startsWith("..") && !isAbsolute(rel));
437
- }
438
-
439
435
  function isTokenConstraints(value: unknown): value is TokenConstraints {
440
436
  if (!value || typeof value !== "object" || Array.isArray(value)) return false;
441
437
  const record = value as Record<string, unknown>;
@@ -0,0 +1,150 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type { SpawnProvider } from "agent-relay-sdk";
3
+ import { resolveProviderSelection, type ProviderEffort } from "agent-relay-sdk/provider-catalog";
4
+ import { workspaceSpawnParams } from "./config-store";
5
+ import { ValidationError } from "./db";
6
+
7
+ /** Resolved provider/model/effort triple — the output shape of every model-resolution wrapper. */
8
+ export interface SpawnModelParams {
9
+ model?: string;
10
+ providerModel?: string;
11
+ effort?: string;
12
+ }
13
+
14
+ /** `sp_`-prefixed correlation id for a spawn request. Single home — was inlined in 5 places. */
15
+ export function generateSpawnRequestId(): string {
16
+ return `sp_${randomUUID()}`;
17
+ }
18
+
19
+ export interface ResolveSpawnModelParamsOptions {
20
+ /**
21
+ * What to do when provider-catalog resolution throws (e.g. unknown model, or
22
+ * effort without a model):
23
+ * - `"throw"` (default) — re-throw as a `ValidationError` (dashboard/mcp/automation paths).
24
+ * - `"passthrough"` — swallow and return the raw `{model, effort}` (managed-policy/restart paths).
25
+ */
26
+ onError?: "throw" | "passthrough";
27
+ /**
28
+ * When `true`, empty input (no model, no effort) returns `{}` instead of resolving
29
+ * the provider's *default* model. The managed-policy and restart paths use this so a
30
+ * spawn with no explicit model carries no model override; the dashboard/mcp/automation
31
+ * paths leave it `false` so the catalog default is injected (their original behavior).
32
+ */
33
+ skipDefaultWhenEmpty?: boolean;
34
+ }
35
+
36
+ /**
37
+ * Resolve `{model, providerModel, effort}` for a spawn payload. Single home for
38
+ * the 5 selection wrappers that each called `resolveProviderSelection` then mapped
39
+ * the result — with inconsistent error handling (some threw, some passed through)
40
+ * and inconsistent empty-input handling (some injected the default model, some didn't).
41
+ */
42
+ export function resolveSpawnModelParams(
43
+ provider: SpawnProvider | string,
44
+ model: string | undefined,
45
+ effort: string | undefined,
46
+ opts: ResolveSpawnModelParamsOptions = {},
47
+ ): SpawnModelParams {
48
+ if (!model && !effort && opts.skipDefaultWhenEmpty) return {};
49
+ try {
50
+ const selection = resolveProviderSelection({ provider: provider as SpawnProvider, model, effort: effort as ProviderEffort | undefined });
51
+ return {
52
+ ...(selection.modelAlias ? { model: selection.modelAlias } : {}),
53
+ ...(selection.providerModel ? { providerModel: selection.providerModel } : {}),
54
+ ...(selection.effort ? { effort: selection.effort } : {}),
55
+ };
56
+ } catch (error) {
57
+ if (opts.onError === "passthrough") {
58
+ return {
59
+ ...(model ? { model } : {}),
60
+ ...(effort ? { effort } : {}),
61
+ };
62
+ }
63
+ throw new ValidationError(error instanceof Error ? error.message : String(error));
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Every field that can appear in a spawn command-bus payload.
69
+ * Optional fields are omitted from the result when undefined.
70
+ */
71
+ export interface BuildSpawnCommandOptions {
72
+ provider: SpawnProvider | string;
73
+ cwd: string;
74
+ /** Correlation id; omitted from the payload when absent (e.g. automation spawns use automationRunId instead). */
75
+ spawnRequestId?: string;
76
+ env: Record<string, string>;
77
+ requestedBy: string;
78
+ requestedAt?: number;
79
+
80
+ /** Resolved {model, providerModel, effort}; spread verbatim. */
81
+ modelParams?: SpawnModelParams;
82
+
83
+ workspaceMode?: string;
84
+ label?: string;
85
+ tags?: string[];
86
+ capabilities?: string[];
87
+ approvalMode?: string;
88
+ permissionMode?: string;
89
+ providerArgs?: string[];
90
+ prompt?: string;
91
+ systemPromptAppend?: string;
92
+ profile?: string;
93
+ agentProfile?: unknown;
94
+ headless?: boolean;
95
+ policyName?: string;
96
+ rig?: string;
97
+ agentId?: string;
98
+ automationId?: string;
99
+ automationRunId?: string;
100
+ orchestratorId?: string;
101
+ requestedVia?: string;
102
+
103
+ /** Extra params merged last (e.g. Claude-resume fields). */
104
+ extra?: Record<string, unknown>;
105
+ }
106
+
107
+ /**
108
+ * Single home for the spawn command-bus payload. Previously hand-assembled in 6
109
+ * places (routes ×3, mcp, managed-policy, automations) which silently drifted —
110
+ * e.g. workspace-symlink params (`workspaceSpawnParams()`) were missing from the
111
+ * mcp and dashboard-quick-spawn paths. Building it here means a new spawn field is
112
+ * added once and every caller gets it. `workspaceSpawnParams()` is included for
113
+ * all callers (it only emits in isolated workspace mode; ignored otherwise).
114
+ */
115
+ export function buildSpawnCommand(opts: BuildSpawnCommandOptions): Record<string, unknown> {
116
+ const def = <T>(value: T | undefined, key: string): Record<string, T> =>
117
+ value === undefined ? {} : { [key]: value };
118
+
119
+ return {
120
+ action: "spawn",
121
+ provider: opts.provider,
122
+ ...(opts.modelParams ?? {}),
123
+ cwd: opts.cwd,
124
+ ...def(opts.workspaceMode, "workspaceMode"),
125
+ ...def(opts.profile, "profile"),
126
+ ...def(opts.agentProfile, "agentProfile"),
127
+ ...workspaceSpawnParams(),
128
+ ...def(opts.rig, "rig"),
129
+ ...def(opts.label, "label"),
130
+ ...def(opts.agentId, "agentId"),
131
+ ...def(opts.tags, "tags"),
132
+ ...def(opts.capabilities, "capabilities"),
133
+ ...def(opts.approvalMode, "approvalMode"),
134
+ ...def(opts.permissionMode, "permissionMode"),
135
+ ...def(opts.providerArgs, "providerArgs"),
136
+ ...def(opts.prompt, "prompt"),
137
+ ...def(opts.systemPromptAppend, "systemPromptAppend"),
138
+ ...def(opts.headless, "headless"),
139
+ ...def(opts.policyName, "policyName"),
140
+ ...def(opts.automationId, "automationId"),
141
+ ...def(opts.automationRunId, "automationRunId"),
142
+ ...def(opts.orchestratorId, "orchestratorId"),
143
+ ...def(opts.requestedVia, "requestedVia"),
144
+ ...def(opts.spawnRequestId, "spawnRequestId"),
145
+ env: opts.env,
146
+ requestedBy: opts.requestedBy,
147
+ requestedAt: opts.requestedAt ?? Date.now(),
148
+ ...(opts.extra ?? {}),
149
+ };
150
+ }
package/src/sse.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { getAgent, getOrchestrator } from "./db";
2
2
  import { emitRelayEvent, subscribeRelayEvents, type RelayEvent } from "./events";
3
3
  import type { ActivityEvent, AgentCard, Message, RelayNotification, Task } from "./types";
4
+ import { isRecord } from "agent-relay-sdk";
4
5
 
5
6
  interface Connection {
6
7
  id: string;
@@ -231,10 +232,6 @@ function notificationTitleForAgent(agentId: string): string {
231
232
  return agent?.label || agent?.name || agentId;
232
233
  }
233
234
 
234
- function isRecord(value: unknown): value is Record<string, unknown> {
235
- return typeof value === "object" && value !== null && !Array.isArray(value);
236
- }
237
-
238
235
  function payloadMessageText(msg: Message): string {
239
236
  const message = msg.payload?.message;
240
237
  if (isRecord(message) && typeof message.text === "string" && message.text.trim()) return message.text.trim();
package/src/steward.ts CHANGED
@@ -1,16 +1,10 @@
1
1
  import { createHash } from "node:crypto";
2
- import { basename, isAbsolute, relative, resolve } from "node:path";
2
+ import { basename, resolve } from "node:path";
3
3
  import { getSpawnPolicy, getStewardConfig, setConfig } from "./config-store";
4
4
  import { listOrchestrators } from "./db";
5
5
  import { getLifecycleManager } from "./lifecycle-manager";
6
6
  import type { Orchestrator, SpawnPolicy, StewardConfig } from "./types";
7
-
8
- // Real path containment (same rule as routes/mcp/lifecycle): never string startsWith.
9
- function pathWithinBase(path: string | undefined, baseDir: string | undefined): boolean {
10
- if (!path || !baseDir) return false;
11
- const rel = relative(resolve(baseDir), resolve(path));
12
- return rel === "" || (!!rel && !rel.startsWith("..") && !isAbsolute(rel));
13
- }
7
+ import { isPathWithinBase } from "./utils";
14
8
 
15
9
  /** Stable, readable, collision-resistant policy name for a repo's steward. */
16
10
  export function repoStewardPolicyName(repoRoot: string): string {
@@ -21,26 +15,27 @@ export function repoStewardPolicyName(repoRoot: string): string {
21
15
 
22
16
  /**
23
17
  * The steward's system prompt — provider-agnostic (works for any provider/model).
24
- * It tells the agent the workflow: take the worktrees it can't auto-land, rebase,
25
- * resolve, run the repo's checks, and land green ones via the relay merge API;
26
- * escalate only what genuinely needs a human.
18
+ * It tells the agent the workflow via the `agent-relay workspace`/`steward` CLI
19
+ * toolkit (#208): see the queue, CLAIM a workspace so auto-merge yields, inspect
20
+ * its diagnostics, rebase/resolve/check, land green ones, release on escalation.
27
21
  */
28
22
  export function buildStewardPrompt(repoRoot: string): string {
29
23
  return [
30
24
  `You are the autonomous repository steward for ${repoRoot}.`,
31
25
  "",
32
- "Multiple agents work this repo in isolated git worktrees. The relay auto-merges branches that fast-forward cleanly; it hands YOU the ones it can't merge conflicts, or a branch whose base moved on (behind > 0). Your job is to land that work with no human in the loop whenever you safely can.",
26
+ "Multiple agents work this repo in isolated git worktrees. The relay auto-merges any branch that rebases cleanly — including ones whose base moved on; it hands YOU only what it can't land deterministically: real merge conflicts, or an unknown/ambiguous merge state. Your job is to land that work with no human in the loop whenever you safely can.",
33
27
  "",
34
- "When woken, gather what needs attention: read the message(s) you were sent (each names a `workspaceId`, `worktreePath`, `branch`, `baseRef`, `status`), and also query `GET /api/workspaces?status=conflict` and `GET /api/workspaces?status=review_requested` for this repo. Handle one workspace at a time — the relay's per-repo merge lease serializes you with any other merger.",
28
+ "Use the `agent-relay` CLI it wraps the relay API and is the supported toolkit. Your session token carries the scopes for it. Handle ONE workspace at a time — the relay's per-repo merge lease serializes you with any other merger.",
35
29
  "",
36
- "For each workspace:",
37
- "1. `cd` into its `worktreePath`.",
38
- "2. Rebase the branch onto its `baseRef` (`git rebase <baseRef>`).",
39
- "3. Resolve any conflicts faithfully — preserve the intent of EVERY side; never silently drop a change. If a conflict needs product judgment you can't make confidently, stop and escalate.",
40
- "4. Run the repo's checks/tests (look for the project's documented commands). Fix trivial breakage you caused while rebasing; do not paper over real failures.",
41
- "5. If green, land it: `POST /api/workspaces/<id>/actions` with `{\"action\":\"merge\",\"strategy\":\"rebase-ff\"}`. The relay acquires the lease and dispatches the merge.",
30
+ "1. See the queue: `agent-relay steward queue` (workspaces in conflict / review_requested / merge_planned for this repo). The wake message(s) also name a `workspaceId`.",
31
+ "2. CLAIM it FIRST: `agent-relay workspace claim --id <id> --purpose steward`. This stops the deterministic auto-merge from racing you while you validate. The claim auto-expires, so a crash never blocks the repo.",
32
+ "3. Understand it: `agent-relay steward inspect <id>` — a diagnostics briefing (owner liveness, live git state, ahead/behind, recorded-vs-live branch mismatch, and a recommended action). `agent-relay steward checks <id>` suggests check commands from the changed files.",
33
+ "4. `cd` into the workspace's `worktreePath` and rebase the branch onto its `baseRef` (`git rebase <baseRef>`). Resolve conflicts faithfully — preserve the intent of EVERY side; never silently drop a change. If a conflict needs product judgment you can't make confidently, stop and escalate.",
34
+ "5. Run the repo's checks/tests (the suggested ones, plus the project's documented commands). Fix trivial breakage you caused while rebasing; do not paper over real failures.",
35
+ "6. If green, land it: `agent-relay workspace land --id <id> --strategy rebase-ff`. The relay rebases onto the latest base, fast-forwards, and pushes to origin under the lease. The land consumes your claim.",
36
+ "7. If you must stop without landing, RELEASE the claim so automation can resume: `agent-relay workspace release --id <id>`, then send a clear, specific summary to the fallback/human target.",
42
37
  "",
43
- "Escalate (do NOT merge) when: checks fail and you can't fix them, the conflict resolution is genuinely ambiguous, or anything looks risky. Send a clear, specific summary to the configured fallback/human target and leave the workspace as-is. Never force-merge a red branch, never `git push --force` to a shared base, never discard committed work.",
38
+ "Escalate (do NOT merge) when: checks fail and you can't fix them, the conflict resolution is genuinely ambiguous, or anything looks risky. Never force-merge a red branch, never `git push --force` to a shared base, never discard committed work.",
44
39
  "",
45
40
  "Be terminal-efficient and decisive. When the queue is empty, you're done — you'll be woken again when there's more.",
46
41
  ].join("\n");
@@ -103,7 +98,7 @@ function stewardPolicyDiffers(existing: SpawnPolicy, desired: SpawnPolicy): bool
103
98
  export function ensureRepoSteward(repoRoot: string): string | null {
104
99
  const config = getStewardConfig();
105
100
  if (!config.enabled) return null;
106
- const owner = listOrchestrators().find((orch) => pathWithinBase(repoRoot, orch.baseDir));
101
+ const owner = listOrchestrators().find((orch) => isPathWithinBase(repoRoot, orch.baseDir));
107
102
  if (!owner) return null;
108
103
 
109
104
  const name = repoStewardPolicyName(repoRoot);
package/src/upgrade.ts CHANGED
@@ -4,6 +4,7 @@ import { join, resolve } from "node:path";
4
4
  import { VERSION } from "./config";
5
5
  import type { RuntimeContracts, RuntimePackageMetadata } from "./contracts";
6
6
  import { defaultRuntimePrefix, runtimeBinPath } from "./runtime-prefix";
7
+ import { RELAY_TOKEN_HEADER } from "agent-relay-sdk";
7
8
 
8
9
  export type UpgradeProvider = "auto" | "all" | "codex" | "claude" | "orchestrator";
9
10
 
@@ -395,7 +396,7 @@ async function npmViewVersion(spec: string): Promise<string | undefined> {
395
396
  async function runningServerVersion(): Promise<string | undefined> {
396
397
  try {
397
398
  const headers: Record<string, string> = {};
398
- if (process.env.AGENT_RELAY_TOKEN) headers["X-Agent-Relay-Token"] = process.env.AGENT_RELAY_TOKEN;
399
+ if (process.env.AGENT_RELAY_TOKEN) headers[RELAY_TOKEN_HEADER] = process.env.AGENT_RELAY_TOKEN;
399
400
  const relayUrl = (process.env.AGENT_RELAY_URL || "http://127.0.0.1:4850").replace(/\/+$/, "");
400
401
  const response = await fetch(`${relayUrl}/api/stats`, { headers });
401
402
  if (!response.ok) return undefined;
@@ -409,7 +410,7 @@ async function runningServerVersion(): Promise<string | undefined> {
409
410
  async function runningOrchestrators(): Promise<UpgradeSnapshot["runningOrchestrators"]> {
410
411
  try {
411
412
  const headers: Record<string, string> = {};
412
- if (process.env.AGENT_RELAY_TOKEN) headers["X-Agent-Relay-Token"] = process.env.AGENT_RELAY_TOKEN;
413
+ if (process.env.AGENT_RELAY_TOKEN) headers[RELAY_TOKEN_HEADER] = process.env.AGENT_RELAY_TOKEN;
413
414
  const relayUrl = (process.env.AGENT_RELAY_URL || "http://127.0.0.1:4850").replace(/\/+$/, "");
414
415
  const response = await fetch(`${relayUrl}/api/orchestrators`, { headers });
415
416
  if (!response.ok) return [];
package/src/utils.ts ADDED
@@ -0,0 +1,38 @@
1
+ import { isAbsolute, relative, resolve } from "node:path";
2
+
3
+ /**
4
+ * Path containment check — is `path` inside (or equal to) `baseDir`?
5
+ *
6
+ * Both inputs are `resolve()`d first for defensive clarity. (Note: `path.relative`
7
+ * already `resolve()`s both args against cwd internally, so this is belt-and-
8
+ * suspenders, not a behavior change — verified: the `security.ts` copy that
9
+ * omitted the explicit `resolve()` produced identical results. The audit's
10
+ * "bug #4" was a false alarm.) Returns `false` when either input is missing.
11
+ *
12
+ * Was hand-copied in 6 server modules (security, workspace-merge, mcp,
13
+ * lifecycle-manager, steward, routes) under three different names. Import this;
14
+ * never re-declare it.
15
+ */
16
+ export function isPathWithinBase(path: string | undefined, baseDir: string | undefined): boolean {
17
+ if (!path || !baseDir) return false;
18
+ const rel = relative(resolve(baseDir), resolve(path));
19
+ return rel === "" || (!!rel && !rel.startsWith("..") && !isAbsolute(rel));
20
+ }
21
+
22
+ /**
23
+ * Parse JSON, returning `fallback` on any failure (non-string input, empty
24
+ * string, or malformed JSON). Never throws.
25
+ *
26
+ * Folds the four fallback-style `parseJson` copies (db, memory-sqlite-broker,
27
+ * provider-catalog-store, automations). The throwing / Buffer / null-returning
28
+ * variants (memory-*-broker operation-context, control-server, insights-db) are
29
+ * deliberately separate — different contracts.
30
+ */
31
+ export function parseJson<T>(raw: unknown, fallback: T): T {
32
+ if (typeof raw !== "string" || !raw) return fallback;
33
+ try {
34
+ return JSON.parse(raw) as T;
35
+ } catch {
36
+ return fallback;
37
+ }
38
+ }
@@ -0,0 +1,28 @@
1
+ import { ValidationError } from "./db";
2
+
3
+ /**
4
+ * Trim + validate an optional string input. Throws `ValidationError` when the
5
+ * value is present but not a string, exceeds `max`, or is required-but-empty.
6
+ * Returns `undefined` for absent/blank non-required values.
7
+ *
8
+ * Single home — was byte-identical in config-store, automations, and routes.
9
+ * The divergent `cleanEnum`/`cleanStringArray` copies (different per-context
10
+ * limits + throw/fallback semantics) are intentionally NOT folded here yet.
11
+ */
12
+ export function cleanString(
13
+ value: unknown,
14
+ field: string,
15
+ opts: { required?: boolean; max?: number } = {},
16
+ ): string | undefined {
17
+ if (value === undefined || value === null) {
18
+ if (opts.required) throw new ValidationError(`${field} required`);
19
+ return undefined;
20
+ }
21
+ if (typeof value !== "string") throw new ValidationError(`${field} must be a string`);
22
+ const trimmed = value.trim();
23
+ if (opts.required && !trimmed) throw new ValidationError(`${field} required`);
24
+ if (opts.max && trimmed.length > opts.max) {
25
+ throw new ValidationError(`${field} must be ${opts.max} characters or fewer`);
26
+ }
27
+ return trimmed || undefined;
28
+ }
@@ -0,0 +1,29 @@
1
+ import type { WorkspaceRecord } from "./types";
2
+
3
+ // A steward claims a workspace before validating/landing so the deterministic
4
+ // auto-merge (Layer 0) doesn't race it (#208 / steward report §1). The claim is a
5
+ // TTL'd lease stored in row metadata, so a dead steward can't block the workspace
6
+ // forever — it expires and auto-merge resumes. Renew by re-claiming.
7
+ export const STEWARD_CLAIM_TTL_MS = Number(process.env.AGENT_RELAY_WORKSPACE_CLAIM_TTL_MS) || 15 * 60_000;
8
+
9
+ export interface WorkspaceClaim {
10
+ by?: string;
11
+ purpose?: string;
12
+ claimedAt?: number;
13
+ expiresAt?: number;
14
+ }
15
+
16
+ /** The active (unexpired) claim on a workspace, or null. Reads row metadata. */
17
+ export function workspaceActiveClaim(ws: Pick<WorkspaceRecord, "metadata">, now: number = Date.now()): WorkspaceClaim | null {
18
+ const meta = ws.metadata as Record<string, unknown> | undefined;
19
+ const claim = meta && typeof meta === "object" ? (meta.stewardClaim as WorkspaceClaim | undefined) : undefined;
20
+ if (!claim || typeof claim !== "object") return null;
21
+ const expiresAt = typeof claim.expiresAt === "number" ? claim.expiresAt : 0;
22
+ return expiresAt > now ? claim : null;
23
+ }
24
+
25
+ /** Metadata patch that records a fresh claim (or clears one when releasing). */
26
+ export function claimMetadataPatch(release: boolean, by?: string, purpose?: string, now: number = Date.now()): Record<string, unknown> {
27
+ if (release) return { stewardClaim: null };
28
+ return { stewardClaim: { by, purpose, claimedAt: now, expiresAt: now + STEWARD_CLAIM_TTL_MS } };
29
+ }
@@ -1,21 +1,25 @@
1
- import { isAbsolute, relative, resolve } from "node:path";
2
1
  import { createCommand } from "./commands-db";
3
2
  import {
4
3
  acquireMergeLease,
4
+ getAgent,
5
5
  listOrchestrators,
6
6
  releaseMergeLease,
7
7
  setMergeLeaseCommand,
8
8
  updateWorkspaceStatus,
9
9
  } from "./db";
10
10
  import type { Command, WorkspaceMergeStrategy, WorkspaceRecord } from "./types";
11
+ import { isPathWithinBase } from "./utils";
11
12
 
12
13
  export interface RequestWorkspaceMergeOptions {
13
14
  /** Who asked for the merge (lease holder + audit). e.g. an agent id, "dashboard", "auto-merge". */
14
15
  requestedBy: string;
15
16
  /** Merge strategy; "auto" lets the host pick pr-vs-rebase-ff. Defaults to "auto". */
16
17
  strategy?: WorkspaceMergeStrategy;
17
- /** Delete the agent branch after a successful land. Defaults to true. */
18
+ /** Delete the agent branch after a successful land. Defaults to true, but is
19
+ * forced false when the workspace owner is still alive (see #204). */
18
20
  deleteBranch?: boolean;
21
+ /** Push the landed base to origin. Defaults to true (host skips when no upstream). */
22
+ push?: boolean;
19
23
  prTitle?: string;
20
24
  prBody?: string;
21
25
  /** Extra metadata merged onto the workspace row when moving to merge_planned. */
@@ -26,11 +30,12 @@ export type RequestWorkspaceMergeResult =
26
30
  | { ok: true; command: Command; workspace: WorkspaceRecord }
27
31
  | { ok: false; status: number; error: string };
28
32
 
29
- // Real path containment (same rule as routes/mcp): never string startsWith.
30
- function pathWithinBase(path: string | undefined, baseDir: string | undefined): boolean {
31
- if (!path || !baseDir) return false;
32
- const rel = relative(resolve(baseDir), resolve(path));
33
- return rel === "" || (!!rel && !rel.startsWith("..") && !isAbsolute(rel));
33
+ // The owner is "alive" while its relay agent exists and isn't offline (online or
34
+ // a borderline-stale disconnect both count don't nuke a worktree on a blip).
35
+ function isOwnerAlive(ownerAgentId: string | undefined): boolean {
36
+ if (!ownerAgentId) return false;
37
+ const agent = getAgent(ownerAgentId);
38
+ return Boolean(agent) && agent!.status !== "offline";
34
39
  }
35
40
 
36
41
  /**
@@ -60,12 +65,18 @@ export function requestWorkspaceMerge(workspace: WorkspaceRecord, opts: RequestW
60
65
  try {
61
66
  // Merge needs a live host: rebasing against a stale base later is unsafe.
62
67
  const onlineOwner = listOrchestrators().find(
63
- (candidate) => candidate.status === "online" && pathWithinBase(workspace.sourceCwd, candidate.baseDir),
68
+ (candidate) => candidate.status === "online" && isPathWithinBase(workspace.sourceCwd, candidate.baseDir),
64
69
  );
65
70
  if (!onlineOwner) {
66
71
  releaseMergeLease({ repoRoot: workspace.repoRoot, workspaceId: workspace.id });
67
72
  return { ok: false, status: 409, error: "no online orchestrator available for workspace merge" };
68
73
  }
74
+ // Never tear down a worktree whose owner is still alive (#204): an online/stale
75
+ // owner keeps its worktree + branch and continues working after the land (the
76
+ // host returns it to `active`). Only an offline/absent owner gets the worktree
77
+ // reclaimed. This overrides the caller's deleteBranch when the owner is alive.
78
+ const ownerAlive = isOwnerAlive(workspace.ownerAgentId);
79
+ const deleteBranch = ownerAlive ? false : (opts.deleteBranch !== false);
69
80
  const updated = updateWorkspaceStatus(workspace.id, "merge_planned", {
70
81
  ...(opts.metadata ?? {}),
71
82
  lastWorkspaceAction: "merge",
@@ -89,7 +100,8 @@ export function requestWorkspaceMerge(workspace: WorkspaceRecord, opts: RequestW
89
100
  baseRef: workspace.baseRef,
90
101
  baseSha: workspace.baseSha,
91
102
  strategy: opts.strategy ?? "auto",
92
- deleteBranch: opts.deleteBranch !== false,
103
+ deleteBranch,
104
+ push: opts.push !== false,
93
105
  prTitle: opts.prTitle,
94
106
  prBody: opts.prBody,
95
107
  requestedBy: opts.requestedBy,