feat(daemon): auto-spawn session for top-level design:#74
Conversation
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
left a comment
There was a problem hiding this comment.
Review by sp-reviewer (architecture) + typescript-reviewer (correctness) — 5 findings (1 blocker, 2 should-fix, 2 nits).
| 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 |
There was a problem hiding this comment.
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.
|
|
||
| debouncedRefreshListDisplay() | ||
|
|
||
| await startDesign(result.threadId, topic) |
There was a problem hiding this comment.
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() | ||
| }) | ||
|
|
There was a problem hiding this comment.
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.
| 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) |
There was a problem hiding this comment.
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.
| topic, | ||
| }), | ||
| }) | ||
|
|
There was a problem hiding this comment.
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.
Summary
design: <topic>is typed at the top-level DM (not in a session thread), auto-spawns a session and starts the design protocol in its threadbuildDesignHostPromptthat keeps it quiet during the design and activates after the brief is postedpromptBuildercallback signature to includethreadIdso the host session knows its threaddesign:behavior unchanged — uses existing session, no spawnlist sessions— they cluttered the view alongside real sessionsFiles changed
daemon/router.tsdesign:match outsideif (msg.isThread)daemon/commands/design.tshandleDesignSpawnIntercepthandlerdaemon/prompts/session.tsbuildDesignHostPromptdaemon/session-lifecycle.tsthreadIdtopromptBuildercalldaemon/sessions.tspromptBuildertype signaturedaemon/dashboard.tsisJoinMemberfrom Home tabdaemon/commands/status.tsisJoinMemberfromlist sessionsand auto-refreshdaemon/__tests__/router-commands.test.tsdesign:regexTest plan
design: <topic>in top-level DM → session spawns, design protocol starts in its threaddesign: <topic>in a session thread → existing behavior (no spawn)list sessionsno longer shows persona/critic sub-sessionsbun test— 274 pass, 0 fail🤖 Generated with Claude Code