Skip to content

feat(daemon): auto-spawn session for top-level design: - #74

Open
kwliang1 wants to merge 1 commit into
sf8193:mainfrom
kwliang1:kevinliang/design-auto-spawn-v2
Open

feat(daemon): auto-spawn session for top-level design:#74
kwliang1 wants to merge 1 commit into
sf8193:mainfrom
kwliang1:kevinliang/design-auto-spawn-v2

Conversation

@kwliang1

@kwliang1 kwliang1 commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator

Summary

Typing design: <topic> at the top-level DM now auto-spawns a session and runs the design protocol in its thread; the host stays quiet until the brief posts, then activates. In-thread design: is unchanged (existing session, no spawn). Persona/critic join-member sessions are now hidden from the Home tab and list sessions — they cluttered the view alongside real sessions.

Why

Top-level design: previously did nothing — you had to spawn a session first, then start the protocol.

Testing

bun test — 274 pass, 0 fail. Covers: top-level design: spawns + starts the protocol; in-thread design: unchanged; host stays quiet then activates; personas hidden from the Home tab and list sessions.

Refs: router.ts (top-level match), commands/design.ts (handleDesignSpawnIntercept), prompts/session.ts (buildDesignHostPrompt), session-lifecycle.ts + sessions.ts (threadId in promptBuilder), dashboard.ts + commands/status.ts (isJoinMember filter).

🤖 Generated with Claude Code

When `design: <topic>` is typed in the top-level DM (not in a session
thread), the daemon now automatically spawns a session and starts the
design protocol in its thread — no manual `spawn:` needed first.

The spawned session uses a custom `buildDesignHostPrompt` that tells it
to stay quiet during the design and help implement the brief once
complete. Extends `promptBuilder` signature to pass `threadId` so the
host session knows its thread.

In-thread behavior is unchanged: `design:` inside a session thread
still uses that session without spawning.

Also hides join-member sessions (design personas, build/review critics)
from the Home tab and `list sessions` output — they cluttered the view
alongside real sessions.

Co-Authored-By: Claude Opus 4.6 (1M context) <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.

Review by sp-reviewer (architecture) + typescript-reviewer (correctness) — 5 findings (1 blocker, 2 should-fix, 2 nits).

Comment thread daemon/sessions.ts
resurrectFrom?: string // tmuxName of predecessor (for lineage in respawn)
joinThread?: string // join existing thread as member (skip thread creation)
promptBuilder?: (sessionId: string, tmuxName: string) => string
promptBuilder?: (sessionId: string, tmuxName: string, threadId: string) => string

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 (flagged by both reviewers): promptBuilder signature widened from 2 to 3 params, but all existing callers in design.ts, build.ts, and adversarial.ts still pass 2-arg lambdas. JS silently ignores the extra arg so no runtime crash, but the type contract is now a lie — if strict function-type checking is ever enabled, every existing caller will fail to compile.

Fix: Make threadId optional (threadId?: string) in the signature, or better yet, don't widen it at all — have handleDesignSpawnIntercept close over threadId from the doSpawnSession result (like every other caller already does) and keep the signature at two params.

Comment thread daemon/commands/design.ts

debouncedRefreshListDisplay()

await startDesign(result.threadId, topic)

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: If startDesign throws, the catch block sends an error message but never cleans up the session spawned on line 46. The user is left with an orphaned tmux session + thread that has no design running in it. The existing in-thread handleDesignIntercept doesn't have this problem because it reuses an existing session.

Fix: In the catch block, kill the spawned session before sending the error message:

} catch (err) {
  // clean up the orphaned session
  const sess = registry.get(result.name)
  if (sess) killSession(sess, 'design failed to start')
  ...
}

(Note: result needs to be declared outside the try block for this, or restructure slightly.)

test('does not match without colon', () => {
expect('design something'.match(DESIGN_RE)).toBeNull()
})

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: Test name says "does not match bare design:" but the assertion on line 289 is not.toBeNull() — it does match, producing an empty topic after .trim(). The test name contradicts the assertion.

More importantly, this exposes a real gap: design: (trailing whitespace only) will pass through the router and spawn a session with topic "". For a top-level command that auto-spawns ~7 sessions (personas + synthesizer + auditor + brief writer), silently accepting an empty topic is risky.

Fix: Either tighten the regex to require at least one non-whitespace char after the colon (e.g. \s*([\s\S]*\S[\s\S]*)$), or add an empty-topic guard in handleDesignSpawnIntercept. Then update the test name/assertion to match.

Comment thread daemon/commands/status.ts
if (lastListMsgs.length === 0) return
const now = Date.now()
const all = [...registry.values()].filter(s => isAlive(s)).sort((a, b) => b.lastActive - a.lastActive)
const all = [...registry.values()].filter(s => isAlive(s) && !s.isJoinMember).sort((a, b) => b.lastActive - a.lastActive)

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: !s.isJoinMember is now added in three places (here, line 163, and dashboard.ts:39). Consider a shared isVisibleSession(s) predicate in sessions.ts to centralize this — it's load-bearing for the user-facing session list and easy to miss if a fourth display site is added.

Comment thread daemon/commands/design.ts
topic,
}),
})

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: Only DM users get a confirmation message with the thread URL. Channel-initiated design: gets just the 🎨 reaction — no link to the spawned thread. Consistent with handleSpawnIntercept, but design is heavier (multi-session), so a brief channel reply with the thread URL might be worth adding.

Comment thread daemon/router.ts
// Top-level design: auto-spawn a session, then start the design in its thread
const topLevelDesignMatch = msg.content.match(/^(?:\/design|design):\s*([\s\S]+)$/i)
if (topLevelDesignMatch && !msg.isThread) {
void handleDesignSpawnIntercept(msg, topLevelDesignMatch[1].trim())

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): Empty topic passes through. The regex [\s\S]+ matches a single space, so "design: " matches and .trim() yields "". This spawns a full design session with an empty topic.

Either tighten the regex to require non-whitespace (e.g. \s*(\S[\s\S]*)) or add a guard:

if (!topLevelDesignMatch[1].trim()) return

The test at line 287 documents this behavior but the test name says "does not match" while the assertion says it does match — both the regex and the test need fixing.

Comment thread daemon/sessions.ts
resurrectFrom?: string // tmuxName of predecessor (for lineage in respawn)
joinThread?: string // join existing thread as member (skip thread creation)
promptBuilder?: (sessionId: string, tmuxName: string) => string
promptBuilder?: (sessionId: string, tmuxName: string, threadId: string) => string

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: The promptBuilder signature now requires 3 params, but all 6 existing callers (in design.ts, build.ts, adversarial.ts) still declare only 2. This compiles due to TS arity compatibility (extra args are ignored), so it's not a runtime bug. But it's a subtle API contract mismatch — consider making threadId optional (threadId?: string) to make the intent explicit.

Comment thread daemon/commands/design.ts
void gateway.react(msg.channelId, msg.id, '🎨').catch(() => {})

try {
const result = await doSpawnSession(topic, msg.channelId, msg.id, {

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 dedup guard against rapid double-invocation. If a user sends design: foo twice quickly, two concurrent calls each create a separate thread via doSpawnSession, so startDesign's designs.has(threadId) check won't catch the duplicate (different threadIds). Consider a per-channel debounce or a "spawning design" lock similar to how other commands handle this.

Comment thread daemon/commands/design.ts

debouncedRefreshListDisplay()

await startDesign(result.threadId, topic)

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: If startDesign throws after doSpawnSession succeeds, the spawned session is left alive with no design running — the host session will sit idle waiting for a brief that never comes. In practice unlikely since the thread is freshly created, but consider killing the session in the catch block for defensive cleanup.

expect('design something'.match(DESIGN_RE)).toBeNull()
})

test('does not match bare design:', () => {

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: Test name says "does not match bare design:" but the assertion is expect(m).not.toBeNull() — it does match. Either rename to reflect the actual behavior (e.g. 'matches design: with trailing whitespace as empty topic') or change the regex/test to actually reject empty topics (see comment on router.ts:252).

Comment thread daemon/commands/status.ts
if (lastListMsgs.length === 0) return
const now = Date.now()
const all = [...registry.values()].filter(s => isAlive(s)).sort((a, b) => b.lastActive - a.lastActive)
const all = [...registry.values()].filter(s => isAlive(s) && !s.isJoinMember).sort((a, b) => b.lastActive - a.lastActive)

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: The isJoinMember filtering (here, line 163, and dashboard.ts:39) is a separate concern from the design-spawn feature. Consider splitting into a separate commit for clean bisectability.

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