Project Switching
Friday runs as a daemon. One process, one ACP session, one working directory. Every file read, every Bash command, every tool call operates relative to that directory. It's baked in at startup and it never moves.
For a single-project workflow, that's fine. For someone maintaining four projects under ~/Code/, it's a daily annoyance.
The workaround is ugly:
- Stop Friday, change the working directory, start Friday again. This kills the session context – every file Claude has read, every decision it has made, gone.
- Don't switch at all. Stay in whatever project you started in and tell Claude the absolute path to every file you need.
/home/friday/Code/other-project/src/thing.tsgets old fast. Ask me how I know.
Both options are friction. The first costs you accumulated state. The second costs you accuracy – one wrong path and Claude is reading the wrong file, confidently explaining code that has nothing to do with what you asked about.
The fix is a command. /project floaty and Friday switches to ~/Code/floaty.dev, resets the session, and primes itself by reading the new project's CLAUDE.md. No restart. No lost messages from the previous project that would confuse things. Just a context switch.
Fuzzy Matching Over a Registry
A project registry – a config file mapping names to paths – would be explicit and predictable. It would also be setup. Every new project needs an entry. Every renamed folder needs an update. For a personal assistant that's supposed to reduce friction, that felt wrong.
The /project command scans the parent directory of the current cwd at command time and fuzzy-matches folder names against the query. No registry, no config. Any new directory in ~/Code/ is immediately available. The matching is deliberately simple – case-insensitive substring:
export type FindProjectResult =
| { match: string }
| { candidates: string[] }
| { error: string };
export function findProject(
query: string,
parentDir: string
): FindProjectResult {
if (query.startsWith('/')) {
try {
const stat = statSync(query);
if (!stat.isDirectory()) {
return { error: `"${query}" is not a directory` };
}
return { match: query };
} catch {
return { error: `"${query}" does not exist` };
}
}
let entries: string[];
try {
entries = readdirSync(parentDir).filter((name) => {
try {
return statSync(join(parentDir, name)).isDirectory();
} catch {
return false;
}
});
} catch {
return { error: `Cannot read parent directory "${parentDir}"` };
}
const lower = query.toLowerCase();
const matches = entries.filter((name) =>
name.toLowerCase().includes(lower)
);
if (matches.length === 0) {
return { error: `No project found matching "${query}"` };
}
if (matches.length === 1) {
return { match: join(parentDir, matches[0]) };
}
const exact = matches.find((name) => name.toLowerCase() === lower);
if (exact) {
return { match: join(parentDir, exact) };
}
return { candidates: matches };
}
This is from
src/lib/project-finder.ts
The function returns one of three shapes: a single match, a list of candidates, or an error. The caller doesn't need to think about scoring or thresholds – it gets an answer it can act on directly.
The first if is an escape hatch. If the query starts with /, it's treated as an absolute path. No scanning, no matching – just validate that the directory exists. This handles the case where you want to switch to something outside the parent directory, or when you know the exact path and don't want to play the fuzzy matching game.
The parent directory itself is dynamic. It comes from path.dirname(session.executionCwd) at command time, not a hardcoded ~/Code/. If Friday has already switched to /home/friday/Code/floaty.dev, the scan looks at /home/friday/Code/ for sibling projects. It follows the current context.
The tradeoff is ambiguity. /project fl would match both floaty and flashcards and the first implementation silently picked whichever came first alphabetically. That was wrong. The fix was to return all candidates when there's more than one match and let the user be more specific. One match: use it. Multiple matches: list them. Exact case-insensitive match among candidates: prefer it.
Friday's Directory vs Claude's Directory
Friday needs to know two directories. The first is its own – cwd – where it finds its database, config, and state store. This is the Friday process's home. It never moves.
The second is executionCwd – the directory that gets passed to the ACP SDK. It's where Claude thinks it's working when we talk to it through Telegram. When we ask Claude to read a file or run a command, executionCwd is the root it operates from.
Before this feature, both point at the same place and neither can change:
get cwd(): string {
return this.#options.cwd;
}
get executionCwd(): string {
return this.#persistentState.getExecutionCwd(this.#options.cwd);
}
This is from
src/acp/session.ts(before)
executionCwd falls back to cwd if nobody has changed it. But there's no setter – Claude's working directory is locked at startup.
Making it mutable takes one line:
set cwd(value: string) {
this.#persistentState.setCwd(value);
}
This is from
src/acp/session.ts(after)
The name is a bit misleading. This moves executionCwd, not Friday's own cwd. The persistent state layer writes the new path to the state store so it survives restarts. Friday keeps its own home directory – it still knows where its database lives – but Claude is now pointed somewhere else.
Moving the directory isn't enough on its own, though. The ACP SDK receives cwd when starting a new session. If there's an existing session ID, the SDK resumes it with whatever directory it already had. We need to force a fresh start:
resetSession(): void {
this.#persistentState.reset();
this.resetUsage();
}
resetSession() clears the stored session ID, the compact boundary, and usage counters. The next prompt() call starts a fresh ACP session pointed at the new executionCwd. This is deliberate – carrying session context from one project into another would be worse than starting fresh. Claude would have memories of files and code from a different codebase, leading to confidently wrong answers about the current one. A clean break is better.
The two calls always happen together: move Claude's directory, then reset the session. The command handler is the one that knows the sequence.
Auto-Priming with CLAUDE.md
Switching the cwd and resetting the session gets Friday pointed at the right directory. But the session is blank – Claude doesn't know what project it's looking at, what the conventions are, or what tools matter. The first thing you'd do after switching is say "read CLAUDE.md." Every time. Without fail.
So the command does it for you:
export function handleProject(deps: CommandDependencies) {
return async (ctx: {
reply: (
text: string,
options?: Record<string, unknown>
) => Promise<unknown>;
match?: string;
chat: { id: number };
}): Promise<void> => {
deps.onReset();
const query = ctx.match?.trim();
if (!query) {
await ctx.reply('Usage: /project <name or path>');
return;
}
const result = findProject(
query,
dirname(deps.session.executionCwd)
);
if ('error' in result) {
await ctx.reply(result.error);
return;
}
if ('candidates' in result) {
await ctx.reply(
`Multiple matches:\n${result.candidates.join('\n')}`
);
return;
}
deps.session.cwd = result.match;
deps.session.resetSession();
console.log(`[project] switched to ${result.match}`);
await ctx.reply(
`Switched to <code>${result.match}</code>. Reading CLAUDE.md…`,
{ parse_mode: 'HTML' }
);
deps.session.prompt(
'Read CLAUDE.md and acknowledge the project context.'
);
deps.onStartPolling(ctx.chat.id);
};
}
This is from
src/telegram/bridge-commands/handle-project.ts
The handler follows the three discriminated union shapes from findProject: error, candidates, or match. The early returns keep the happy path unindented.
The interesting bit is the last three lines. After confirming the switch to the user, the handler fires a prompt – "Read CLAUDE.md and acknowledge the project context." – which triggers a full Claude turn. Claude reads the file, processes the project-specific rules, and responds. By the time that response arrives in Telegram, the session is primed and ready to work.
deps.onReset() at the top clears any pending Telegram state – queued messages, in-progress tool approvals, anything from the previous project that shouldn't bleed into the new context. deps.onStartPolling(ctx.chat.id) at the bottom starts listening for Claude's response to the auto-prompt. The command handler doesn't wait for Claude to finish – it can't, because prompt() is synchronous by design (we covered that in the previous chapter). It tells the user the switch happened, kicks off the read, and gets out of the way.
Conclusion
/project floaty finds one match, switches, done. But fuzzy matching means we have to handle the cases where the query isn't specific enough.
/project fl might match floaty, flashcards, and flags-demo. Rather than guessing, Friday lists the candidates and waits:
Multiple matches:
floaty.dev
flashcards-app
flags-demo
The user sends /project floaty and gets the right one. It's an extra round trip, but it's better than silently picking the wrong project. The threshold is binary: exactly one match means go, more than one means ask.
There's one exception. If the query is an exact case-insensitive match for one of the candidates – /project floaty when there's a folder literally called floaty alongside floaty-tools – the exact match wins. This handles the common case where a short name is a substring of a longer one.
The absolute path escape hatch handles everything else. /project /home/friday/Code/something-obscure skips the scan entirely and validates the path directly. It's there for two reasons: switching to projects outside the parent directory, and bypassing ambiguity when you know exactly where you're going. Power users reach for it when fuzzy matching would be a waste of time.
The error cases are straightforward. No match: No project found matching "xyz". Bad absolute path: "/path/to/thing" does not exist. Not a directory: "/path/to/file.txt" is not a directory. Each error tells you what went wrong without making you guess.
The whole feature is three files: a cwd setter on Session, the project finder module, and the command handler. Total test count for the repo went from 285 to 298. It's a small change because the architecture was already the right shape – one session, one cwd, commands that manipulate both. The feature exposed a knob that was missing. It turns out the hardest part wasn't the implementation. It was not building it sooner.