feat: proj:<name> spawn prefix (spawn in a SPAWN_CWD subdirectory) - #60
feat: proj:<name> spawn prefix (spawn in a SPAWN_CWD subdirectory)#60kwliang1 wants to merge 1 commit into
Conversation
…directory) Adds a `proj:<name>` (alias `dir:<name>`) topic prefix that spawns a session directly inside an existing subdirectory of SPAWN_CWD — the real checkout, no worktree. Mirrors the existing `worktree:` prefix parsing. Motivation: per-project tooling that keys off the session cwd (e.g. claude-mem, which shards memory by git-repo-root / directory) currently pools everything under SPAWN_CWD because every plain spawn shares that one cwd. `proj:<name>` lets each project's sessions launch in their own directory so such tooling shards per project, without the ephemeral-branch overhead of `worktree:`. Usage: `proj:car-ship-prototype <your task>` Compile check: `bun build daemon.ts` / `bun build bridge.ts` both pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
sf8193
left a comment
There was a problem hiding this comment.
PR #60 Review — proj:<name> spawn prefix
Reviewed by two specialist agents (sp-reviewer: architecture, typescript-reviewer: correctness). Findings merged and deduplicated below.
Not in diff but noted: The spawn_session tool schema in daemon/bridge-dispatch.ts has no proj parameter — agent sessions can't discover or use this feature via the tool contract. The router also has no spawn-proj: shorthand (unlike spawn-wt: for worktrees). Both make proj: invisible to callers without reading source.
| let effectiveCwd = spawnCwd | ||
| if (projTarget) { | ||
| // Spawn in an existing subdirectory of SPAWN_CWD (real checkout, no worktree). | ||
| const projDir = resolve(spawnCwd, projTarget) |
There was a problem hiding this comment.
Blocker — path traversal (flagged by both reviewers)
resolve(spawnCwd, projTarget) will happily resolve ../../etc or an absolute path. There is no guard that projDir stays inside spawnCwd. A user with Discord spawn access can set effectiveCwd to any filesystem path.
Fix:
if (!projDir.startsWith(spawnCwd + '/')) {
throw new Error(`proj target "${projTarget}" escapes SPAWN_CWD`)
}| let worktreeRepo: string | undefined | ||
| let worktreePath: string | undefined | ||
| let effectiveCwd = spawnCwd | ||
| if (projTarget) { |
There was a problem hiding this comment.
Should-fix — no mutual exclusion with worktreeTarget (flagged by both reviewers)
worktree:foo proj:bar topic will set both targets. The projTarget block runs first and sets effectiveCwd, then the worktreeTarget block silently overwrites it. The proj directive is discarded without error.
Fix: add a guard after both parsers:
if (projTarget && worktreeTarget) {
throw new Error('proj:/dir: and worktree:/wt: prefixes cannot be combined')
}| // subdirectory of SPAWN_CWD (no worktree), so per-project tooling (e.g. claude-mem) | ||
| // shards memory/state by that directory instead of pooling everything under SPAWN_CWD. | ||
| let projTarget: string | undefined | ||
| const projMatch = topic.match(/^(?:proj|dir):(\S+)\s+/) |
There was a problem hiding this comment.
Should-fix — resume/respawn loses proj CWD (flagged by both reviewers)
The proj: prefix is stripped from topic here. SessionInfo stores the stripped topic but never records projTarget or projDir. When tryResume/tryRespawn reconstruct a session from dead.topic, the proj-pinned CWD is lost — the session respawns under spawnCwd instead of projDir.
Fix: persist projDir (or projTarget) in the registry entry alongside worktreeRepo/worktreePath, and pass it through the respawn path.
|
|
||
| // Parse proj:<name> / dir:<name> prefix -- spawn directly inside an existing | ||
| // subdirectory of SPAWN_CWD (no worktree), so per-project tooling (e.g. claude-mem) | ||
| // shards memory/state by that directory instead of pooling everything under SPAWN_CWD. |
There was a problem hiding this comment.
Should-fix — bare proj:name silently falls through
The regex /^(?:proj|dir):(\S+)\s+/ requires trailing whitespace. A bare proj:myapp (no trailing text) won't match — the prefix becomes part of the topic/prompt with no error. The worktree regex has the same pattern, but this is a new instance worth fixing.
Fix: /^(?:proj|dir):(\S+)(?:\s+|$)/ and handle the end-of-string case when slicing.
| throw new Error(`proj target "${projTarget}" does not exist at ${projDir}`) | ||
| } | ||
| effectiveCwd = projDir | ||
| process.stderr.write(`daemon: spawning in project dir ${projDir}\n`) |
There was a problem hiding this comment.
Nit — log line missing session name
All other spawn-path log lines include tmuxName for grepability (e.g. daemon: spawn ${tmuxName}: ...). This line omits it. tmuxName is already assigned by this point (line 166).
| let effectiveCwd = spawnCwd | ||
| if (projTarget) { | ||
| // Spawn in an existing subdirectory of SPAWN_CWD (real checkout, no worktree). | ||
| const projDir = resolve(spawnCwd, projTarget) |
There was a problem hiding this comment.
Should-fix (flagged by both reviewers): Path traversal — projTarget comes from user-controlled chat input and is passed directly to resolve(spawnCwd, projTarget). A value like ../../etc resolves outside SPAWN_CWD, and existsSync will pass for any existing directory. The worktree codepath has a partial guard (git repo check), but proj has none.
Suggestion:
if (!resolve(spawnCwd, projTarget).startsWith(resolve(spawnCwd) + '/')) {
throw new Error(`proj target "${projTarget}" resolves outside SPAWN_CWD`)
}| let worktreeRepo: string | undefined | ||
| let worktreePath: string | undefined | ||
| let effectiveCwd = spawnCwd | ||
| if (projTarget) { |
There was a problem hiding this comment.
Should-fix (flagged by both reviewers): proj: and worktree: are not mutually exclusive. Both prefixes can be parsed from the same topic (e.g. proj:foo worktree:bar task). If both are set, effectiveCwd is set to projDir first, then silently overwritten by the worktree block. The proj prefix becomes a no-op with no indication to the user.
Suggestion — reject the combination:
if (projTarget && worktreeTarget) {
throw new Error('proj: and worktree: prefixes are mutually exclusive')
}| if (projTarget) { | ||
| // Spawn in an existing subdirectory of SPAWN_CWD (real checkout, no worktree). | ||
| const projDir = resolve(spawnCwd, projTarget) | ||
| if (!existsSync(projDir)) { |
There was a problem hiding this comment.
Nit: existsSync returns true for regular files too. If projTarget resolves to a file, the cd in the tmux command will fail and the session dies immediately, but it's already registered in the registry.
Consider statSync(projDir).isDirectory() instead of existsSync.
| // Parse proj:<name> / dir:<name> prefix -- spawn directly inside an existing | ||
| // subdirectory of SPAWN_CWD (no worktree), so per-project tooling (e.g. claude-mem) | ||
| // shards memory/state by that directory instead of pooling everything under SPAWN_CWD. | ||
| let projTarget: string | undefined |
There was a problem hiding this comment.
Should-fix: The regex requires trailing whitespace (\s+), so bare proj:foo (no subsequent topic) silently fails to match. The session spawns in SPAWN_CWD with literal topic proj:foo. This matches the worktreeTarget pattern (consistent), but is a confusing silent failure. Consider either making the trailing space optional (\s*) with a fallback topic, or throwing if the prefix is detected but unparseable.
| if (!existsSync(projDir)) { | ||
| throw new Error(`proj target "${projTarget}" does not exist at ${projDir}`) | ||
| } | ||
| effectiveCwd = projDir |
There was a problem hiding this comment.
Should-fix: projTarget / effectiveCwd is not persisted into the session registry entry. Recovery paths (tryResume, tryRespawn) that re-spawn from thread metadata will lose the proj context and fall back to SPAWN_CWD — silently breaking per-project memory isolation on respawn.
Store projTarget (or the resolved effectiveCwd) in the registry, and reconstruct the prefix on respawn.
| // Parse proj:<name> / dir:<name> prefix -- spawn directly inside an existing | ||
| // subdirectory of SPAWN_CWD (no worktree), so per-project tooling (e.g. claude-mem) | ||
| // shards memory/state by that directory instead of pooling everything under SPAWN_CWD. | ||
| let projTarget: string | undefined |
There was a problem hiding this comment.
Nit: (?:proj|dir) introduces dir: as a silent synonym. If intentional, it should appear in help text / command docs. If it's a prototyping leftover, pick one name — two names for the same feature is unnecessary maintenance surface.
What
A
proj:<name>(aliasdir:<name>) spawn prefix launches the session in an existingSPAWN_CWD/<name>subdirectory — the real checkout, no worktree. Mirrors theworktree:/wt:prefix.Why
cwd-keyed tooling pools every plain spawn under the single
SPAWN_CWD. Example: claude-mem shards memory by directory, so it labels everything one project.proj:gives each project its own cwd — without a worktree's throwaway branch (right for isolated builds, wrong for normal interactive work on the real checkout).How
Parse
^(?:proj|dir):(\S+)\s+off the topic, strip it, resolveresolve(SPAWN_CWD, name)(error if the dir is missing). Mutually exclusive withworktree:; plain spawns are unchanged.Testing
bun build daemon.ts+bun build bridge.tsgreen.🤖 Generated with Claude Code