Skip to content

Commit 8613dfe

Browse files
committed
Fix a chosen project being forgotten when the daemon restarts
1 parent e7717f9 commit 8613dfe

7 files changed

Lines changed: 529 additions & 29 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ Mobile remote control for **desktop coding agents** (Claude Code, Codex, Gemini
1616
- **Spawned children need `error` *and* `spawn` listeners** — an unresolvable command emits `error` async, and unhandled it kills the daemon and every other session.
1717
- **The useful half of an agent failure is JSON-RPC `data.details`** (sometimes double-encoded), and it arrives twice: as message text, then a rejection. `humanError()` (`errors.ts`) normalises at the one point all transports share. A clean exit is not an error — closing a session always fires `kind: "exit"`.
1818
- **`resolveWorkspace()` (`workspace.ts`) is the only cwd default** (explicit → `PEW2_WORKSPACE` → daemon cwd → home). Under launchd cwd is `/` and agents treat cwd as project root — GG Coder died on `mkdir '/.gg'`. Session start *and* the capability probe go through it; a third spawn path reintroduces the bug.
19+
- **A client's `cwd` is honoured only via `knownProject()`, and that check has to outlive the process** — the app re-sends its chosen project on every reconnect and every new conversation, for days. Browsed paths lived in memory alone, so a daemon restart made a genuine pick unknown, and both readers failed *silently*: `workspace.status` answered with the agent's previous project (the composer named a repo the user never picked) and `session.start` then refused the same path. Acceptance is written to `known-projects.json`, and the check reads the probe cache when no probe has landed yet — the app asks in the same breath as the probe it triggers, so recognition must never be a race against a spawn. `session.resume` still falls back rather than refusing (it arrives before the probe), but it may not *guess a project*: it takes the cwd the agent recorded for that conversation, because dropping to the provider's last workspace reopened a chat about one repo with the agent rooted in another.
1920
- **Two prefs files, deliberately.** `config-prefs.json` (one file for all providers — read-modify-write it) seeds the *next new* session; `session-prefs.json` is replayed on `session/load`, because ACP resumes a transcript but hands back the agent's *default* selectors. Never apply the provider pref to a resume — that rewrites a desk-started conversation to match the phone's last pick.
2021
- **Session ids die with the daemon; the app's list does not.** `needsResume()` (`agentHistory.ts`) is the single rule for opening a conversation, and an undefined `activeSessions` (older daemon) means *assume live*. Without it a restarted daemon answers `Unknown session '<id>'`.
2122
- **Projects fold from the *uncapped* session list**, before `SESSION_HISTORY_LIMIT` (`projects.ts`) — the capped list is a recent-work window, so grouping it hides every repo not touched this week. Message counts hydrate per project on demand, never at probe time.

packages/daemon/src/handler.test.ts

Lines changed: 166 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,24 @@
88
* anyone holding the pairing token can send.
99
*/
1010
import { expect, test } from "bun:test";
11+
import { mkdtempSync } from "node:fs";
12+
import { tmpdir } from "node:os";
13+
import { join } from "node:path";
1114
import { handleMessage } from "./handler.js";
1215
import { Daemon } from "./index.js";
16+
import { readKnownProjects } from "./known-projects.js";
17+
import { writeProbeCache } from "./probe-cache.js";
18+
19+
/**
20+
* A state directory of this test's own.
21+
*
22+
* Accepting a project writes it down (`known-projects.json`), so a daemon built
23+
* on the real environment would both read the developer's own projects and add
24+
* to them.
25+
*/
26+
function scratchHome(): NodeJS.ProcessEnv {
27+
return { ...process.env, PEW2_HOME: mkdtempSync(join(tmpdir(), "pew2-handler-")) };
28+
}
1329

1430
/**
1531
* A daemon whose project bookkeeping is real and whose spawning is not.
@@ -18,8 +34,8 @@ import { Daemon } from "./index.js";
1834
* so they are the production ones; only the parts that would start an agent are
1935
* replaced.
2036
*/
21-
function stubbed() {
22-
const daemon = new Daemon({ id: "test", name: "test" }, true);
37+
function stubbed(env: NodeJS.ProcessEnv = scratchHome()) {
38+
const daemon = new Daemon({ id: "test", name: "test" }, true, env);
2339
const started: Array<{ providerId: string; cwd: string }> = [];
2440
const resumed: Array<{ agentSessionId: string; cwd: string }> = [];
2541
const listed: string[] = [];
@@ -110,6 +126,103 @@ test("a browsed directory can still be started in", async () => {
110126
expect(announced?.cwd).toBe("/Users/someone/code/api");
111127
});
112128

129+
test("a project chosen from the app outlives the daemon that offered it", async () => {
130+
// The bug this exists for: browsing to a project, then restarting the daemon
131+
// (an update, a crash, a development restart) while the app still holds that
132+
// project selected. The app re-sends the same path on every reconnect and
133+
// every new conversation, so "offered seconds ago" was never the real
134+
// lifetime of a pick.
135+
const env = scratchHome();
136+
const first = stubbed(env);
137+
first.daemon.rememberOfferedWorkspaces(["/Users/someone/code/api"]);
138+
await send(first.daemon, {
139+
t: "workspace.status",
140+
providerId: "echo",
141+
cwd: "/Users/someone/code/api",
142+
});
143+
// Written down on acceptance, not merely on offer.
144+
expect(await readKnownProjects(env)).toEqual(["/Users/someone/code/api"]);
145+
146+
// A second process, with nothing in memory: exactly what the app reconnects
147+
// to after an update.
148+
const restarted = stubbed(env);
149+
const out = await send(restarted.daemon, {
150+
t: "session.start",
151+
providerId: "echo",
152+
cwd: "/Users/someone/code/api",
153+
});
154+
155+
expect(restarted.started).toEqual([
156+
{ providerId: "echo", cwd: "/Users/someone/code/api" },
157+
]);
158+
expect(out.find((m) => m.t === "session.started")?.cwd).toBe("/Users/someone/code/api");
159+
});
160+
161+
test("a restart does not silently answer with a different project", async () => {
162+
// The visible half of the same bug, and the reason it was so confusing: the
163+
// composer named the agent's *previous* project, so a new conversation looked
164+
// locked to the last repo no matter which one was picked.
165+
const env = scratchHome();
166+
const first = stubbed(env);
167+
first.daemon.rememberOfferedWorkspaces(["/Users/someone/code/api"]);
168+
await send(first.daemon, {
169+
t: "workspace.status",
170+
providerId: "echo",
171+
cwd: "/Users/someone/code/api",
172+
});
173+
174+
const restarted = stubbed(env);
175+
const out = await send(restarted.daemon, {
176+
t: "workspace.status",
177+
providerId: "echo",
178+
cwd: "/Users/someone/code/api",
179+
});
180+
181+
expect(out[0]?.t).toBe("workspace");
182+
expect(out[0]?.cwd).toBe("/Users/someone/code/api");
183+
expect(out[0]?.folder).toBe("api");
184+
});
185+
186+
test("a directory that was never published stays unknown across a restart", async () => {
187+
// The containment is what survives, not the path: nothing is stored unless
188+
// this daemon published it and a client then chose it.
189+
const env = scratchHome();
190+
await send(stubbed(env).daemon, { t: "session.start", providerId: "echo", cwd: "/etc" });
191+
expect(await readKnownProjects(env)).toEqual([]);
192+
193+
const out = await send(stubbed(env).daemon, {
194+
t: "session.start",
195+
providerId: "echo",
196+
cwd: "/etc",
197+
});
198+
expect(out[0]?.t).toBe("error");
199+
expect(out[0]?.message).toContain("unknown project");
200+
});
201+
202+
test("a project with agent history is recognised before any probe has landed", async () => {
203+
// The app asks where the next prompt will land the instant it reconnects,
204+
// in the same breath as the probe that fills the in-memory project history.
205+
// Reading only that map made recognising a genuine project a race against an
206+
// agent spawn, and losing it looked identical to the bug above: the composer
207+
// named whatever the agent had open last.
208+
const env = scratchHome();
209+
await writeProbeCache(
210+
"echo",
211+
{ canResume: true, configOptions: [], sessions: [] },
212+
env,
213+
[{ sessionId: "s1", cwd: "/Users/someone/code/api", updatedAt: "2026-08-10T12:00:00Z" }],
214+
);
215+
216+
const { daemon, started } = stubbed(env);
217+
await send(daemon, {
218+
t: "session.start",
219+
providerId: "echo",
220+
cwd: "/Users/someone/code/api",
221+
});
222+
223+
expect(started).toEqual([{ providerId: "echo", cwd: "/Users/someone/code/api" }]);
224+
});
225+
113226
test("naming no project at all still falls back to the agent's last one", async () => {
114227
// The phone has no file picker, so most sessions arrive with no `cwd`. That
115228
// path never involved a client-supplied string and must keep working.
@@ -181,6 +294,57 @@ test("resuming falls back instead of refusing an unrecognised directory", async
181294
});
182295
});
183296

297+
test("reopening a conversation uses the project the agent recorded for it", async () => {
298+
// The fallback above may not guess a *different* project. Dropping to the
299+
// provider's last workspace reopens a conversation about one repo with the
300+
// agent rooted in another — every file tool in that turn then works on the
301+
// wrong project, and the `session.started` tells every client to file the
302+
// conversation there too.
303+
//
304+
// The agent's own history says where its session lives, so no guess is
305+
// needed. This is also the older app, which sends no `cwd` at all.
306+
const env = scratchHome();
307+
await writeProbeCache("echo", { canResume: true, configOptions: [], sessions: [] }, env, [
308+
{ sessionId: "agent-1", cwd: "/Users/someone/code/api", updatedAt: "2026-08-10T12:00:00Z" },
309+
]);
310+
const { daemon, resumed } = stubbed(env);
311+
312+
const out = await send(daemon, {
313+
t: "session.resume",
314+
providerId: "echo",
315+
agentSessionId: "agent-1",
316+
});
317+
318+
expect(resumed).toEqual([{ agentSessionId: "agent-1", cwd: "/Users/someone/code/api" }]);
319+
// And the announcement agrees, so no client files it under the fallback.
320+
expect(out.find((m) => m.t === "session.started")?.cwd).toBe("/Users/someone/code/api");
321+
});
322+
323+
test("the agent's record beats a client naming some other project", async () => {
324+
// Both paths are ones this daemon published, so this is not containment — it
325+
// is which of two honest answers is about *this* conversation. A phone can be
326+
// holding a stale row, or simply the project it currently has selected.
327+
const env = scratchHome();
328+
await writeProbeCache("echo", { canResume: true, configOptions: [], sessions: [] }, env, [
329+
{ sessionId: "agent-1", cwd: "/Users/someone/code/api", updatedAt: "2026-08-10T12:00:00Z" },
330+
]);
331+
const { daemon, resumed } = stubbed(env);
332+
daemon.rememberOfferedWorkspaces(["/Users/someone/code/www"]);
333+
334+
await send(daemon, {
335+
t: "session.resume",
336+
providerId: "echo",
337+
agentSessionId: "agent-1",
338+
cwd: "/Users/someone/code/www",
339+
});
340+
341+
expect(resumed).toEqual([{ agentSessionId: "agent-1", cwd: "/Users/someone/code/api" }]);
342+
// The path the client sent is not even consulted, so it is not filed as a
343+
// project this client opened — it named where it happened to be looking, not
344+
// what it was reopening.
345+
expect(await readKnownProjects(env)).toEqual(["/Users/someone/code/api"]);
346+
});
347+
184348
test("a push token is kept against the device that proved who it was", async () => {
185349
const { daemon } = stubbed();
186350

packages/daemon/src/handler.ts

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,8 @@ export interface HandlerContext {
5454
* refusal deliberately says nothing about whether the path exists — answering
5555
* that for an arbitrary string is a filesystem oracle in its own right.
5656
*/
57-
function namedProject(daemon: Daemon, providerId: string, cwd: string): string {
58-
const known = daemon.knownProject(providerId, cwd);
57+
async function namedProject(daemon: Daemon, providerId: string, cwd: string): Promise<string> {
58+
const known = await daemon.knownProject(providerId, cwd);
5959
if (!known) throw new Error("unknown project");
6060
return known;
6161
}
@@ -156,7 +156,7 @@ export async function handleMessage(raw: string, ctx: HandlerContext): Promise<v
156156
// broadcast: it is a menu choice on one phone, not a change to the
157157
// session log every client shares.
158158
const providerId = message.providerId;
159-
const projectCwd = namedProject(daemon, providerId, message.cwd);
159+
const projectCwd = await namedProject(daemon, providerId, message.cwd);
160160
const sessions = await daemon.sessionsForProject(providerId, projectCwd);
161161
reply({
162162
t: "provider.sessions",
@@ -183,11 +183,27 @@ export async function handleMessage(raw: string, ctx: HandlerContext): Promise<v
183183
// break reopening a session every time the daemon was updated. The
184184
// containment is unchanged either way: the client's string is used only
185185
// when this daemon published it, and otherwise never reaches a spawn.
186-
const named = message.cwd
187-
? daemon.knownProject(message.providerId, message.cwd)
188-
: undefined;
189-
const workspace =
190-
named ?? (await daemon.lastWorkspace(message.providerId)) ?? cwd;
186+
//
187+
// What the fallback must not do is *guess a different project*, which is
188+
// what it used to: dropping to the provider's last workspace reopened a
189+
// conversation about one repo with the agent rooted in another, and told
190+
// every client to file it there. So the agent's own record for this
191+
// conversation comes first — it is the authority on where its own
192+
// session lives, it needs no client to be up to date, and it is right
193+
// even for an older app that sends no `cwd` at all.
194+
const recorded = await daemon.agentSessionCwd(
195+
message.providerId,
196+
message.agentSessionId,
197+
);
198+
// Only asked when the agent had no record: checking a path also files it
199+
// as a project this client has opened, and the one it sent here is not
200+
// the one being opened.
201+
const named =
202+
recorded ??
203+
(message.cwd
204+
? await daemon.knownProject(message.providerId, message.cwd)
205+
: undefined);
206+
const workspace = named ?? (await daemon.lastWorkspace(message.providerId)) ?? cwd;
191207
const pending = daemon.beginResumeSession(
192208
message.providerId,
193209
message.agentSessionId,
@@ -235,15 +251,15 @@ export async function handleMessage(raw: string, ctx: HandlerContext): Promise<v
235251
// has no file picker, and defaulting to the home directory gives the
236252
// agent no project to work in and no project commands to offer.
237253
const workspace = message.cwd
238-
? namedProject(daemon, message.providerId, message.cwd)
254+
? await namedProject(daemon, message.providerId, message.cwd)
239255
: ((await daemon.lastWorkspace(message.providerId)) ?? cwd);
240256
const sessionId = await daemon.startSession(message.providerId, workspace);
241257
broadcast({
242258
t: "session.started",
243259
sessionId,
244260
providerId: message.providerId,
245261
// The project the session was actually started in, which is not
246-
// always the one asked for: an unrecognised `cwd` falls back to the
262+
// always the one asked for: a request naming no project opens in the
247263
// agent's last workspace above. Sending the resolved value means a
248264
// client files the row where the work is really happening, and a
249265
// second device — which never saw the request — can file it at all.
@@ -402,7 +418,7 @@ export async function handleMessage(raw: string, ctx: HandlerContext): Promise<v
402418
// directory the answer already listed.
403419
const chosen =
404420
message.providerId && message.cwd
405-
? daemon.knownProject(message.providerId, message.cwd)
421+
? await daemon.knownProject(message.providerId, message.cwd)
406422
: undefined;
407423
const root =
408424
(message.sessionId ? daemon.sessionCwd(message.sessionId) : undefined) ??

0 commit comments

Comments
 (0)