CWD Persistence
Friday can switch projects. /project floaty changes the working directory, resets the session, reads the new CLAUDE.md, and you're off. It works perfectly – until you kill the process.
Friday runs as a systemd service. /kill in Telegram exits the process and systemd restarts it. That's the designed recovery path – clear a stuck session, pick up a code change, get back to work. But the working directory is held in a private field on the Session instance – and nowhere else. When the process dies, the project context dies with it.
The practical cost is a daily annoyance. You switch to a project in the morning, work on it for hours, then /kill to pick up a code change. Friday comes back with no memory of where it was. You type /project floaty again. And again after the next restart. And again.
The fix is small. The kind of small that makes you wonder how it wasn't there from the start.
The Before Picture
The /project command does three things: set session.cwd to the new path, reset the ACP session, and fire a prompt to read CLAUDE.md. The setter updates a private field on the Session class. That field is read when starting a new ACP session, passed as the cwd option to the SDK. It works correctly for the lifetime of the process.
But src/index.ts always constructs the session the same way:
const session = new Session(state, {
cwd: process.cwd(),
// ...
});
This is from
src/index.ts(before)
process.cwd() is the process's working directory – always the Friday repo root, because that's where systemd starts the service. No matter what you've switched to, the next startup resets to Friday's own repo.
Persist in the Setter
The cwd setter already existed – I added it for project switching. We can add persistence directly in the setter: one line, right after the field assignment:
set cwd(value: string) {
this.#options.cwd = value;
this.#state.set(ACTIVE_CWD_KEY, value);
}
This is from
src/acp/session.ts
Every cwd change is now persisted automatically. No caller needs to remember to save. No separate "persist project" command. The setter is the single source of truth for cwd changes, and now it's the single source of truth for persistence too.
Reading the value back on startup is a static method – called before the Session is even constructed:
static resolveStartupCwd(state: StateStore, fallback: string): string {
const stored = state.get(ACTIVE_CWD_KEY);
if (!stored) return fallback;
if (!existsSync(stored)) {
state.delete(ACTIVE_CWD_KEY);
return fallback;
}
return stored;
}
This is from
src/acp/session.ts
Read the key, check whether the path still exists on disk, return it or fall back. The existsSync check matters more than it looks – we'll come back to it.
The startup code in index.ts uses it like this:
const defaultCwd = process.cwd();
const startupCwd = Session.resolveStartupCwd(state, defaultCwd);
const cwdRestored = startupCwd !== defaultCwd;
This is from
src/index.ts
Three lines. The session gets constructed with the restored path, and a boolean tracks whether a restoration happened – because a couple of things need to behave differently when it does.
The Quiet Details
Restoring the working directory isn't enough on its own. If Friday starts in the right folder but doesn't know the project's rules, it'll make mistakes on the first prompt. The session is in the right place but Claude's context is empty.
When startup detects a restored cwd, it fires the same prompt that /project uses:
if (cwdRestored) {
session.prompt('Read CLAUDE.md and acknowledge the project context.');
}
This is from
src/index.ts
The session is primed with the right instructions before you send your first message. Without this, Friday would be in the right directory but wouldn't know the project's rules – the worst kind of silent failure.
The Telegram "Alive!" broadcast gets a similar treatment. When a cwd is restored, it says:
Alive! (resumed in /home/friday/Code/floaty.dev)
It's a confidence signal. You know the restoration worked before you type anything.
/clear needs attention too. Without this fix, clearing the session replies with "Session reset" and nothing else. If you're in a non-default project, resetting the session drops Claude's knowledge of the project context. We can include the current project path in the reply, and if the cwd is non-default, automatically re-read CLAUDE.md:
this.#bot.command('clear', async (ctx) => {
this.#resetMessage();
this.#session.resetSession();
this.#renderer.resetCostWarned();
const cwd = this.#session.cwd;
const isNonDefault = cwd !== process.cwd();
if (isNonDefault) {
await ctx.reply(
`Session reset. Project: ${cwd}\nReading CLAUDE.md…`
);
this.#session.prompt(
'Read CLAUDE.md and acknowledge the project context.'
);
this.#startPolling(ctx.chat.id);
} else {
await ctx.reply(`Session reset. Project: ${cwd}`);
}
});
This is from
src/telegram/bridge.ts
The session reset doesn't silently lose your project context anymore.
The Stale Path Problem
Remember the existsSync check in resolveStartupCwd? Here's why it's there.
Friday uses git worktrees – temporary directories that get deleted when a feature branch is merged. The next chapter covers worktrees in detail, but the relevant bit is simple: a worktree is a directory that can disappear. If you switch to a worktree, work on it all day, then merge the branch and clean up, the stored cwd points at a directory that no longer exists.
Without the existence check, every subsequent tool call fails with path-not-found errors. Friday starts, tries to operate in a deleted directory, and breaks on the first prompt. The fix is three lines:
if (!existsSync(stored)) {
state.delete(ACTIVE_CWD_KEY);
return fallback;
}
This is from
src/acp/session.ts
If the stored path is gone, delete the stale key and fall back to the default. Friday starts normally instead of starting broken. It's the kind of edge case that won't surface in testing but will ruin your morning the first time it happens in practice.
Conclusion
One key in the state table. One check on startup. One line in the setter. You switch projects, kill the process, and Friday comes back where you left it.