Skip to content

Commit d7fba24

Browse files
hugocasaclaude
andcommitted
fix: restore agent status by prefixing the runtime control URL
Since #271 the server mounts every project's routes under `/${prefix}`, but the agent hooks' control URL was built without the prefix, so status events POSTed to `/api/runtime/events` fell through to the SPA and Claude's status stopped updating. - backend: thread the project prefix into the runtime's control base URL (buildControlBaseUrl + ProjectManager createRuntime), so hook events hit the project's prefixed `/api/runtime/events` route. - cli: in-process add/open/refresh resolve the prefix best-effort from the running server (resolveProjectPrefix). When it can't be resolved (no server), no control URL is configured and no control.env is written — cleaner than a wrong, unrouted URL; status self-heals on next open/refresh from a dashboard. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 7c9cc56 commit d7fba24

10 files changed

Lines changed: 195 additions & 24 deletions

backend/src/__tests__/lifecycle-service.test.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,9 @@ function makeLifecycleService(
284284
onFinished?: (branch: string) => void | Promise<void>;
285285
} = {},
286286
sessionDiscovery: SessionDiscoveryGateway = { listSessionIds: async () => [] },
287+
// `null` explicitly configures no control reporting (passing `undefined` would
288+
// fall back to the default below).
289+
controlBaseUrl: string | null = "http://127.0.0.1:5111",
287290
): LifecycleService {
288291
const reconciliation = new ReconciliationService({
289292
config,
@@ -295,7 +298,7 @@ function makeLifecycleService(
295298

296299
return new LifecycleService({
297300
projectRoot: repoRoot,
298-
controlBaseUrl: "http://127.0.0.1:5111",
301+
controlBaseUrl: controlBaseUrl ?? undefined,
299302
getControlToken: async () => "secret-token",
300303
config,
301304
archiveState: new ArchiveStateService(git.resolveWorktreeGitDir(repoRoot)),
@@ -407,6 +410,38 @@ describe("LifecycleService", () => {
407410
expect(state?.session.paneCount).toBe(2);
408411
});
409412

413+
it("writes no control.env when no control base URL is configured", async () => {
414+
const repoRoot = await initRepo();
415+
const runtime = new ProjectRuntime();
416+
const tmux = new FakeTmuxGateway();
417+
// The CLI leaves controlBaseUrl undefined when it can't resolve the project
418+
// prefix (no server running). We'd rather write no control.env than one with
419+
// a wrong (unrouted) URL — the dashboard rewrites it on next open/refresh.
420+
const lifecycle = makeLifecycleService(
421+
repoRoot,
422+
tmux,
423+
runtime,
424+
new FakeDockerGateway(),
425+
new FakeHookRunner(),
426+
TEST_CONFIG,
427+
new BunGitGateway(),
428+
new FakeAutoNameService(),
429+
{},
430+
{ listSessionIds: async () => [] },
431+
null,
432+
);
433+
434+
await lifecycle.createWorktree({ branch: "feature/search" });
435+
436+
const worktreePath = join(repoRoot, "__worktrees", "feature", "search");
437+
const gitDir = new BunGitGateway().resolveWorktreeGitDir(worktreePath);
438+
const paths = getWorktreeStoragePaths(gitDir);
439+
440+
expect(await Bun.file(paths.controlEnvPath).exists()).toBe(false);
441+
// The runtime env (unrelated to control reporting) is still written.
442+
expect(await Bun.file(paths.runtimeEnvPath).exists()).toBe(true);
443+
});
444+
410445
it("creates one managed worktree per selected agent from one task branch", async () => {
411446
const repoRoot = await initRepo();
412447
const runtime = new ProjectRuntime();

backend/src/__tests__/project-manager.test.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,16 +33,19 @@ function makeManager(initial: ProjectEntry[] = []): {
3333
registry: ProjectsRegistry & { entries: ProjectEntry[] };
3434
loopCalls: Map<string, string[]>;
3535
createdFor: string[];
36+
createdWith: Array<{ projectDir: string; port: number; prefix: string }>;
3637
} {
3738
const registry = fakeRegistry(initial);
3839
const loopCalls = new Map<string, string[]>();
3940
const createdFor: string[] = [];
41+
const createdWith: Array<{ projectDir: string; port: number; prefix: string }> = [];
4042
const manager = new ProjectManager<FakeRuntime>({
4143
registry,
4244
port: 5111,
4345
resolveRoot: (path) => path,
44-
createRuntime: ({ projectDir }) => {
46+
createRuntime: ({ projectDir, port, prefix }) => {
4547
createdFor.push(projectDir);
48+
createdWith.push({ projectDir, port, prefix });
4649
return { config: { name: `name:${projectDir}` } };
4750
},
4851
createLoops: (project: ManagedProject<FakeRuntime>): ProjectLoopController => {
@@ -56,7 +59,7 @@ function makeManager(initial: ProjectEntry[] = []): {
5659
};
5760
},
5861
});
59-
return { manager, registry, loopCalls, createdFor };
62+
return { manager, registry, loopCalls, createdFor, createdWith };
6063
}
6164

6265
describe("ProjectManager", () => {
@@ -73,6 +76,18 @@ describe("ProjectManager", () => {
7376
expect(loopCalls.get("alpha")).toEqual(["startLight"]);
7477
});
7578

79+
it("passes the derived prefix to createRuntime so the runtime can build a prefixed control URL", () => {
80+
const { manager, createdWith } = makeManager();
81+
82+
manager.add("/repo/alpha");
83+
manager.add("/repo/alpha-clone/alpha");
84+
85+
expect(createdWith).toEqual([
86+
{ projectDir: "/repo/alpha", port: 5111, prefix: "alpha" },
87+
{ projectDir: "/repo/alpha-clone/alpha", port: 5111, prefix: "alpha-2" },
88+
]);
89+
});
90+
7691
it("addEphemeral serves the project in-memory but does not persist it", () => {
7792
const { manager, registry, loopCalls } = makeManager();
7893

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { describe, expect, it } from "bun:test";
2+
import { buildControlBaseUrl } from "../runtime";
3+
4+
// Regression: the server mounts every project's routes under `/${prefix}`
5+
// (see server.ts buildServeRoutes), so the control base URL that agent hooks
6+
// POST to must carry the same prefix or the events fall through to the SPA and
7+
// Claude's status never updates.
8+
describe("buildControlBaseUrl", () => {
9+
it("includes the project prefix so it matches the prefixed server route", () => {
10+
expect(buildControlBaseUrl(5111, "webmux")).toBe("http://127.0.0.1:5111/webmux");
11+
});
12+
13+
it("keeps an unprefixed URL for an empty prefix (legacy single-project edge)", () => {
14+
expect(buildControlBaseUrl(5111, "")).toBe("http://127.0.0.1:5111");
15+
});
16+
17+
it("returns undefined when there is no prefix, disabling control reporting", () => {
18+
// The CLI passes undefined when it can't resolve a prefix (no server
19+
// running). No control URL is better than a wrong one: the agent's hooks
20+
// no-op cleanly instead of POSTing to an unrouted path.
21+
expect(buildControlBaseUrl(5111, undefined)).toBeUndefined();
22+
});
23+
});

backend/src/runtime.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,26 @@ import { WorktreeCreationTracker } from "./services/worktree-creation-service";
1717
export interface WebmuxRuntimeOptions {
1818
projectDir?: string;
1919
port?: number;
20+
/** URL-path prefix the server mounts this project's routes under. Agent hooks
21+
* POST status events to the control URL, which must carry the same prefix or
22+
* the events fall through to the SPA and Claude's status never updates. */
23+
prefix?: string;
2024
onCreateProgress?: (progress: CreateWorktreeProgress) => void | Promise<void>;
2125
}
2226

27+
/** Base URL agent hooks POST runtime events to. The server serves each project
28+
* under `/${prefix}` (see server.ts buildServeRoutes), so the control URL must
29+
* include the prefix to hit the project's `/api/runtime/events` route.
30+
*
31+
* `undefined` prefix means control reporting is not configured (the CLI passes
32+
* it when it can't resolve a prefix — no server running). We return undefined
33+
* rather than an unprefixed URL so no control.env is written and the agent's
34+
* hooks no-op cleanly instead of POSTing to an unrouted path. */
35+
export function buildControlBaseUrl(port: number, prefix: string | undefined): string | undefined {
36+
if (prefix === undefined) return undefined;
37+
return prefix ? `http://127.0.0.1:${port}/${prefix}` : `http://127.0.0.1:${port}`;
38+
}
39+
2340
export interface WebmuxRuntime {
2441
port: number;
2542
projectDir: string;
@@ -63,7 +80,7 @@ export function createWebmuxRuntime(options: WebmuxRuntimeOptions = {}): WebmuxR
6380
});
6481
const lifecycleService = new LifecycleService({
6582
projectRoot: projectDir,
66-
controlBaseUrl: `http://127.0.0.1:${port}`,
83+
controlBaseUrl: buildControlBaseUrl(port, options.prefix),
6784
getControlToken: loadControlToken,
6885
config,
6986
archiveState: archiveStateService,

backend/src/server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2688,7 +2688,7 @@ BOUND_PORT = actualPort(server, PORT);
26882688
manager = new ProjectManager({
26892689
registry: createProjectsRegistry(),
26902690
port: BOUND_PORT,
2691-
createRuntime: ({ projectDir, port }) => createWebmuxRuntime({ projectDir, port }),
2691+
createRuntime: ({ projectDir, port, prefix }) => createWebmuxRuntime({ projectDir, port, prefix }),
26922692
createLoops: (project): ProjectLoopController => {
26932693
const app = createProjectApp(project.runtime, project.prefix);
26942694
apps.set(project.prefix, app);

backend/src/services/lifecycle-service.ts

Lines changed: 28 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { type DockerGateway } from "../adapters/docker";
1919
import { buildProjectSessionName, buildWorktreeParkingWindowName, buildWorktreeWindowName, type TmuxGateway } from "../adapters/tmux";
2020
import { captureNewSessionId, type SessionDiscoveryGateway } from "../adapters/session-discovery";
2121
import type { AgentId, ProfileConfig, ProjectConfig, RuntimeKind } from "../domain/config";
22-
import { ROOT_TAB_ID, type OneshotMeta, type WorktreeCreationPhase, type WorktreeMeta, type WorktreeSource, type WorktreeTab } from "../domain/model";
22+
import { ROOT_TAB_ID, type ControlEnvMap, type OneshotMeta, type WorktreeCreationPhase, type WorktreeMeta, type WorktreeSource, type WorktreeTab } from "../domain/model";
2323
import {
2424
activeTabId as readActiveTabId,
2525
appendTab,
@@ -156,7 +156,9 @@ export interface CreateWorktreeProgress {
156156

157157
export interface LifecycleServiceDependencies {
158158
projectRoot: string;
159-
controlBaseUrl: string;
159+
/** Absent when control reporting isn't configured (e.g. a CLI runtime that
160+
* couldn't resolve the project's route prefix). No control.env is written. */
161+
controlBaseUrl?: string;
160162
getControlToken: () => Promise<string>;
161163
config: ProjectConfig;
162164
archiveState: ArchiveStateService;
@@ -976,8 +978,7 @@ export class LifecycleService {
976978
allocatedPorts: await this.allocatePorts(),
977979
runtimeEnvExtras: { WEBMUX_WORKTREE_PATH: resolved.entry.path },
978980
dotenvValues,
979-
controlUrl: this.controlUrl(profile.runtime),
980-
controlToken: await this.deps.getControlToken(),
981+
...(await this.controlEnvFields(profile.runtime)),
981982
});
982983
}
983984

@@ -1006,13 +1007,17 @@ export class LifecycleService {
10061007
}, dotenvValues);
10071008
await writeRuntimeEnv(input.gitDir, runtimeEnv);
10081009

1009-
const controlEnv = buildControlEnvMap({
1010-
controlUrl: this.controlUrl(input.meta.runtime),
1011-
controlToken: await this.deps.getControlToken(),
1012-
worktreeId: input.meta.worktreeId,
1013-
branch: input.meta.branch,
1014-
});
1015-
await writeControlEnv(input.gitDir, controlEnv);
1010+
const controlUrl = this.controlUrl(input.meta.runtime);
1011+
let controlEnv: ControlEnvMap | null = null;
1012+
if (controlUrl) {
1013+
controlEnv = buildControlEnvMap({
1014+
controlUrl,
1015+
controlToken: await this.deps.getControlToken(),
1016+
worktreeId: input.meta.worktreeId,
1017+
branch: input.meta.branch,
1018+
});
1019+
await writeControlEnv(input.gitDir, controlEnv);
1020+
}
10161021

10171022
return {
10181023
meta: input.meta,
@@ -1241,10 +1246,20 @@ export class LifecycleService {
12411246
}
12421247
}
12431248

1244-
private controlUrl(runtime: RuntimeKind): string {
1249+
private controlUrl(runtime: RuntimeKind): string | undefined {
1250+
if (!this.deps.controlBaseUrl) return undefined;
12451251
return `${buildRuntimeControlBaseUrl(this.deps.controlBaseUrl, runtime)}/api/runtime/events`;
12461252
}
12471253

1254+
/** Control URL + token, paired so they're always both set or both absent
1255+
* (initializeManagedWorktree rejects one without the other). Empty when
1256+
* control reporting isn't configured. */
1257+
private async controlEnvFields(runtime: RuntimeKind): Promise<{ controlUrl?: string; controlToken?: string }> {
1258+
const controlUrl = this.controlUrl(runtime);
1259+
if (!controlUrl) return {};
1260+
return { controlUrl, controlToken: await this.deps.getControlToken() };
1261+
}
1262+
12481263
private async removeResolvedWorktree(
12491264
resolved: ResolvedLifecycleWorktree,
12501265
): Promise<void> {
@@ -1378,8 +1393,7 @@ export class LifecycleService {
13781393
startupEnvValues: await this.buildStartupEnvValues(input.envOverrides),
13791394
allocatedPorts: await this.allocatePorts(),
13801395
runtimeEnvExtras: { WEBMUX_WORKTREE_PATH: worktreePath },
1381-
controlUrl: this.controlUrl(profile.runtime),
1382-
controlToken: await this.deps.getControlToken(),
1396+
...(await this.controlEnvFields(profile.runtime)),
13831397
deleteBranchOnRollback,
13841398
source,
13851399
...(input.oneshot ? { oneshot: input.oneshot } : {}),

backend/src/services/project-manager.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ export interface ProjectManagerDeps<R extends RuntimeLike = WebmuxRuntime> {
4343
port: number;
4444
/** Build the per-project runtime — pass `createWebmuxRuntime` in production.
4545
* `R` is inferred from the return type, so tests can supply a typed stub. */
46-
createRuntime: (options: { projectDir: string; port: number }) => R;
46+
createRuntime: (options: { projectDir: string; port: number; prefix: string }) => R;
4747
/** Resolve an arbitrary path to its canonical project (git) root. */
4848
resolveRoot?: (path: string) => string;
4949
/** Build the loop controller for a project. Defaults to a no-op (wired in
@@ -58,7 +58,7 @@ export class ProjectManager<R extends RuntimeLike = WebmuxRuntime> {
5858
private readonly registry: ProjectsRegistry;
5959
private readonly port: number;
6060
private readonly resolveRoot: (path: string) => string;
61-
private readonly createRuntime: (options: { projectDir: string; port: number }) => R;
61+
private readonly createRuntime: (options: { projectDir: string; port: number; prefix: string }) => R;
6262
private readonly createLoops: (project: ManagedProject<R>) => ProjectLoopController;
6363
private readonly projects = new Map<string, ManagedProject<R>>();
6464
private readonly loops = new Map<string, ProjectLoopController>();
@@ -148,7 +148,7 @@ export class ProjectManager<R extends RuntimeLike = WebmuxRuntime> {
148148
}
149149

150150
const prefix = deriveProjectPrefix(root, this.projects.keys());
151-
const runtime = this.createRuntime({ projectDir: root, port: this.port });
151+
const runtime = this.createRuntime({ projectDir: root, port: this.port, prefix });
152152
const entry: ProjectEntry = {
153153
path: root,
154154
name: runtime.config.name,

bin/src/shared.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,3 +96,18 @@ export async function resolveProjectBaseUrl(port: number, projectDir: string = p
9696
}
9797
return `${base}/${match.prefix}`;
9898
}
99+
100+
/** The server serves each project under `/<prefix>`, so an in-process runtime
101+
* that writes `control.env` must embed that prefix or the agent's status hooks
102+
* POST to an unrouted path. Best-effort: returns `undefined` when the prefix
103+
* can't be resolved (no server running, or the repo isn't a served project) so
104+
* the caller writes no control URL at all rather than a wrong one. Status
105+
* self-heals when the worktree is next opened/refreshed from a dashboard. */
106+
export async function resolveProjectPrefix(port: number, projectDir: string = process.cwd()): Promise<string | undefined> {
107+
try {
108+
const base = await resolveProjectBaseUrl(port, projectDir);
109+
return new URL(base).pathname.replace(/^\/+|\/+$/g, "") || undefined;
110+
} catch {
111+
return undefined;
112+
}
113+
}

bin/src/worktree-commands.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,32 @@ describe("runWorktreeCommand", () => {
409409
expect(stdout).toEqual(["Created worktree feature/remote-branch"]);
410410
});
411411

412+
it("passes the server-resolved project prefix to the runtime so control.env carries it", async () => {
413+
const { runtime } = makeRuntime();
414+
const createdWith: Array<{ prefix?: string }> = [];
415+
416+
const exitCode = await runWorktreeCommand(
417+
{
418+
command: "add",
419+
args: ["feature/search", "--detach"],
420+
projectDir: "/repo",
421+
port: 5111,
422+
},
423+
{
424+
createRuntime: (options) => {
425+
createdWith.push({ prefix: options.prefix });
426+
return runtime;
427+
},
428+
resolveProjectPrefix: async () => "myproject",
429+
stdout: () => {},
430+
switchToTmuxWindow: () => {},
431+
},
432+
);
433+
434+
expect(exitCode).toBe(0);
435+
expect(createdWith).toEqual([{ prefix: "myproject" }]);
436+
});
437+
412438
it("skips tmux switch when --detach is passed to add", async () => {
413439
const { runtime } = makeRuntime();
414440
const stdout: string[] = [];

0 commit comments

Comments
 (0)