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.
| // 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()) |
There was a problem hiding this comment.
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()) returnThe 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.
| 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.
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.
| void gateway.react(msg.channelId, msg.id, '🎨').catch(() => {}) | ||
|
|
||
| try { | ||
| const result = await doSpawnSession(topic, msg.channelId, msg.id, { |
There was a problem hiding this comment.
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.
|
|
||
| debouncedRefreshListDisplay() | ||
|
|
||
| await startDesign(result.threadId, topic) |
There was a problem hiding this comment.
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:', () => { |
There was a problem hiding this comment.
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).
| 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: 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.
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-threaddesign:is unchanged (existing session, no spawn). Persona/critic join-member sessions are now hidden from the Home tab andlist 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-leveldesign:spawns + starts the protocol; in-threaddesign:unchanged; host stays quiet then activates; personas hidden from the Home tab andlist sessions.Refs: router.ts (top-level match), commands/design.ts (
handleDesignSpawnIntercept), prompts/session.ts (buildDesignHostPrompt), session-lifecycle.ts + sessions.ts (threadIdinpromptBuilder), dashboard.ts + commands/status.ts (isJoinMemberfilter).🤖 Generated with Claude Code