Skip to content

feat: proj:<name> spawn prefix (spawn in a SPAWN_CWD subdirectory) - #60

Draft
kwliang1 wants to merge 1 commit into
sf8193:mainfrom
kwliang1:feat/proj-spawn-cwd
Draft

feat: proj:<name> spawn prefix (spawn in a SPAWN_CWD subdirectory)#60
kwliang1 wants to merge 1 commit into
sf8193:mainfrom
kwliang1:feat/proj-spawn-cwd

Conversation

@kwliang1

@kwliang1 kwliang1 commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator

What

A proj:<name> (alias dir:<name>) spawn prefix launches the session in an existing SPAWN_CWD/<name> subdirectory — the real checkout, no worktree. Mirrors the worktree:/wt: prefix.

proj:car-ship-prototype <your task>

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, resolve resolve(SPAWN_CWD, name) (error if the dir is missing). Mutually exclusive with worktree:; plain spawns are unchanged.

Testing

bun build daemon.ts + bun build bridge.ts green.

🤖 Generated with Claude Code

…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 sf8193 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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+/)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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`)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)) {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants