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).
What
Adds a
proj:<name>(aliasdir:<name>) topic prefix todoSpawnSessionthat spawns the session directly inside an existing subdirectory ofSPAWN_CWD— the real checkout, no worktree. Mirrors the existingworktree:/wt:prefix parsing.Why
Per-project tooling that keys off the session cwd currently can't distinguish projects, because every plain spawn shares the single
SPAWN_CWD. Concretely: claude-mem shards memory by git-repo-root / directory — but with all sessions launching atSPAWN_CWDit labels everything one project and pools it.proj:<name>lets each project's sessions launch in their own directory, so such tooling shards per project, without the ephemeral-branch / cleanup overhead ofworktree:(which is great for isolated build tasks but deletes its branch on exit — wrong fit for normal interactive work on the real checkout).How
^(?:proj|dir):(\S+)\s+from the topic (same pattern asworktree:), strip it so it doesn't leak into thread names/prompts.effectiveCwd = resolve(SPAWN_CWD, name), erroring if the dir doesn't exist.existsSyncis already imported.proj:andworktree:are mutually exclusive (only one matches^).Testing
bun build daemon.ts --target=bun✅bun build bridge.ts --target=bun✅Notes
Draft for review. No change to default behavior — plain spawns still land at
SPAWN_CWD.🤖 Generated with Claude Code