specrails-hub 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,410 @@
1
+ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
2
+ import { EventEmitter } from 'events'
3
+ import { Readable } from 'stream'
4
+
5
+ vi.mock('child_process', () => ({
6
+ spawn: vi.fn(),
7
+ execSync: vi.fn(),
8
+ }))
9
+
10
+ vi.mock('tree-kill', () => ({
11
+ default: vi.fn(),
12
+ }))
13
+
14
+ // Mock command-resolver to return a resolved prompt (different from raw command)
15
+ vi.mock('./command-resolver', () => ({
16
+ resolveCommand: vi.fn((command: string) => 'Resolved prompt for: ' + command),
17
+ }))
18
+
19
+ import { spawn as mockSpawn } from 'child_process'
20
+ import treeKill from 'tree-kill'
21
+ import { ProposalManager } from './proposal-manager'
22
+ import { initDb, createProposal, getProposal } from './db'
23
+ import type { DbInstance } from './db'
24
+
25
+ // ─── Mock helpers ─────────────────────────────────────────────────────────────
26
+
27
+ function createMockChildProcess() {
28
+ const child = new EventEmitter() as any
29
+ child.stdout = new Readable({ read() {} })
30
+ child.stderr = new Readable({ read() {} })
31
+ child.pid = 42000
32
+ child.kill = vi.fn()
33
+ return child
34
+ }
35
+
36
+ function pushLine(child: any, line: string) {
37
+ child.stdout.push(line + '\n')
38
+ }
39
+
40
+ function finishProcess(child: any, code: number): Promise<void> {
41
+ return new Promise((resolve) => {
42
+ child.stdout.push(null)
43
+ setImmediate(() => {
44
+ child.emit('close', code)
45
+ resolve()
46
+ })
47
+ })
48
+ }
49
+
50
+ function assistantEvent(text: string): string {
51
+ return JSON.stringify({
52
+ type: 'assistant',
53
+ message: { content: [{ type: 'text', text }] },
54
+ })
55
+ }
56
+
57
+ function resultEvent(sessionId: string): string {
58
+ return JSON.stringify({ type: 'result', session_id: sessionId })
59
+ }
60
+
61
+ function getBroadcastedByType(broadcast: ReturnType<typeof vi.fn>, type: string) {
62
+ return broadcast.mock.calls
63
+ .map((args) => args[0] as Record<string, unknown>)
64
+ .filter((msg) => msg.type === type)
65
+ }
66
+
67
+ const TEST_PROPOSAL_ID = 'proposal-test-001'
68
+ const TEST_CWD = '/fake/project/path'
69
+
70
+ // ─── Test suite ───────────────────────────────────────────────────────────────
71
+
72
+ describe('ProposalManager', () => {
73
+ let db: DbInstance
74
+ let broadcast: ReturnType<typeof vi.fn>
75
+ let pm: ProposalManager
76
+
77
+ beforeEach(() => {
78
+ vi.resetAllMocks()
79
+ db = initDb(':memory:')
80
+ broadcast = vi.fn()
81
+ pm = new ProposalManager(broadcast, db, TEST_CWD)
82
+ })
83
+
84
+ afterEach(() => {
85
+ vi.restoreAllMocks()
86
+ })
87
+
88
+ function setupProposal(id = TEST_PROPOSAL_ID, idea = 'Add dark mode') {
89
+ createProposal(db, { id, idea })
90
+ return id
91
+ }
92
+
93
+ // ─── startExploration ──────────────────────────────────────────────────────
94
+
95
+ describe('startExploration', () => {
96
+ it('spawns claude with correct args', async () => {
97
+ const proposalId = setupProposal()
98
+ const child = createMockChildProcess()
99
+ vi.mocked(mockSpawn).mockReturnValue(child as any)
100
+
101
+ const explorePromise = pm.startExploration(proposalId, 'Add dark mode')
102
+ await finishProcess(child, 0)
103
+ await explorePromise
104
+
105
+ expect(vi.mocked(mockSpawn)).toHaveBeenCalledWith(
106
+ 'claude',
107
+ expect.arrayContaining([
108
+ '--dangerously-skip-permissions',
109
+ '--output-format', 'stream-json',
110
+ '--verbose',
111
+ '-p',
112
+ ]),
113
+ expect.objectContaining({ cwd: TEST_CWD })
114
+ )
115
+ })
116
+
117
+ it('broadcasts proposal_stream deltas as text arrives', async () => {
118
+ const proposalId = setupProposal()
119
+ const child = createMockChildProcess()
120
+ vi.mocked(mockSpawn).mockReturnValue(child as any)
121
+
122
+ const explorePromise = pm.startExploration(proposalId, 'Add dark mode')
123
+
124
+ pushLine(child, assistantEvent('## Feature Title\n'))
125
+ pushLine(child, assistantEvent('Add Dark Mode'))
126
+ pushLine(child, resultEvent('sess-001'))
127
+ await finishProcess(child, 0)
128
+ await explorePromise
129
+
130
+ const streamMsgs = getBroadcastedByType(broadcast, 'proposal_stream')
131
+ expect(streamMsgs.length).toBeGreaterThan(0)
132
+ expect(streamMsgs[0].proposalId).toBe(proposalId)
133
+ expect(streamMsgs[0].delta).toBeTruthy()
134
+ })
135
+
136
+ it('captures session_id from result event', async () => {
137
+ const proposalId = setupProposal()
138
+ const child = createMockChildProcess()
139
+ vi.mocked(mockSpawn).mockReturnValue(child as any)
140
+
141
+ const explorePromise = pm.startExploration(proposalId, 'Add dark mode')
142
+ pushLine(child, assistantEvent('Some content'))
143
+ pushLine(child, resultEvent('sess-captured-001'))
144
+ await finishProcess(child, 0)
145
+ await explorePromise
146
+
147
+ const row = getProposal(db, proposalId)!
148
+ expect(row.session_id).toBe('sess-captured-001')
149
+ })
150
+
151
+ it('broadcasts proposal_ready with full markdown on close(0)', async () => {
152
+ const proposalId = setupProposal()
153
+ const child = createMockChildProcess()
154
+ vi.mocked(mockSpawn).mockReturnValue(child as any)
155
+
156
+ const explorePromise = pm.startExploration(proposalId, 'Add dark mode')
157
+ pushLine(child, assistantEvent('## Feature Title\nAdd Dark Mode'))
158
+ pushLine(child, resultEvent('sess-002'))
159
+ await finishProcess(child, 0)
160
+ await explorePromise
161
+
162
+ const readyMsgs = getBroadcastedByType(broadcast, 'proposal_ready')
163
+ expect(readyMsgs).toHaveLength(1)
164
+ expect(readyMsgs[0].proposalId).toBe(proposalId)
165
+ expect(readyMsgs[0].markdown).toContain('Add Dark Mode')
166
+ })
167
+
168
+ it('updates proposal status to review on success', async () => {
169
+ const proposalId = setupProposal()
170
+ const child = createMockChildProcess()
171
+ vi.mocked(mockSpawn).mockReturnValue(child as any)
172
+
173
+ const explorePromise = pm.startExploration(proposalId, 'Add dark mode')
174
+ pushLine(child, assistantEvent('Content'))
175
+ pushLine(child, resultEvent('sess-003'))
176
+ await finishProcess(child, 0)
177
+ await explorePromise
178
+
179
+ const row = getProposal(db, proposalId)!
180
+ expect(row.status).toBe('review')
181
+ })
182
+
183
+ it('broadcasts proposal_error and resets status to input on close(non-0)', async () => {
184
+ const proposalId = setupProposal()
185
+ const child = createMockChildProcess()
186
+ vi.mocked(mockSpawn).mockReturnValue(child as any)
187
+
188
+ const explorePromise = pm.startExploration(proposalId, 'Add dark mode')
189
+ await finishProcess(child, 1)
190
+ await explorePromise
191
+
192
+ const errorMsgs = getBroadcastedByType(broadcast, 'proposal_error')
193
+ expect(errorMsgs).toHaveLength(1)
194
+ expect(errorMsgs[0].proposalId).toBe(proposalId)
195
+
196
+ const row = getProposal(db, proposalId)!
197
+ expect(row.status).toBe('input')
198
+ })
199
+
200
+ it('does nothing if proposal not found in DB', async () => {
201
+ vi.mocked(mockSpawn)
202
+
203
+ await pm.startExploration('nonexistent-id', 'some idea')
204
+
205
+ const errorMsgs = getBroadcastedByType(broadcast, 'proposal_error')
206
+ expect(errorMsgs).toHaveLength(1)
207
+ expect(vi.mocked(mockSpawn)).not.toHaveBeenCalled()
208
+ })
209
+ })
210
+
211
+ // ─── sendRefinement ────────────────────────────────────────────────────────
212
+
213
+ describe('sendRefinement', () => {
214
+ it('spawns with --resume <session_id>', async () => {
215
+ const proposalId = setupProposal()
216
+ // Set up proposal in review state with session_id
217
+ const db2 = db
218
+ db2.prepare("UPDATE proposals SET status = 'review', session_id = ? WHERE id = ?")
219
+ .run('sess-existing', proposalId)
220
+
221
+ const child = createMockChildProcess()
222
+ vi.mocked(mockSpawn).mockReturnValue(child as any)
223
+
224
+ const refinePromise = pm.sendRefinement(proposalId, 'Make it simpler')
225
+ pushLine(child, assistantEvent('Simplified'))
226
+ pushLine(child, resultEvent('sess-refined'))
227
+ await finishProcess(child, 0)
228
+ await refinePromise
229
+
230
+ const spawnArgs = vi.mocked(mockSpawn).mock.calls[0][1] as string[]
231
+ expect(spawnArgs).toContain('--resume')
232
+ expect(spawnArgs).toContain('sess-existing')
233
+ })
234
+
235
+ it('broadcasts proposal_refined on success', async () => {
236
+ const proposalId = setupProposal()
237
+ db.prepare("UPDATE proposals SET status = 'review', session_id = 'sess-r1' WHERE id = ?").run(proposalId)
238
+
239
+ const child = createMockChildProcess()
240
+ vi.mocked(mockSpawn).mockReturnValue(child as any)
241
+
242
+ const refinePromise = pm.sendRefinement(proposalId, 'Refine this')
243
+ pushLine(child, assistantEvent('Refined content'))
244
+ pushLine(child, resultEvent('sess-r2'))
245
+ await finishProcess(child, 0)
246
+ await refinePromise
247
+
248
+ const refinedMsgs = getBroadcastedByType(broadcast, 'proposal_refined')
249
+ expect(refinedMsgs).toHaveLength(1)
250
+ expect(refinedMsgs[0].proposalId).toBe(proposalId)
251
+ expect(refinedMsgs[0].markdown).toContain('Refined content')
252
+ })
253
+
254
+ it('returns early and broadcasts error if session_id is null', async () => {
255
+ const proposalId = setupProposal()
256
+ // session_id is null by default
257
+
258
+ await pm.sendRefinement(proposalId, 'Some feedback')
259
+
260
+ const errorMsgs = getBroadcastedByType(broadcast, 'proposal_error')
261
+ expect(errorMsgs).toHaveLength(1)
262
+ expect(vi.mocked(mockSpawn)).not.toHaveBeenCalled()
263
+ })
264
+ })
265
+
266
+ // ─── createIssue ──────────────────────────────────────────────────────────
267
+
268
+ describe('createIssue', () => {
269
+ it('extracts GitHub URL from response and broadcasts proposal_issue_created', async () => {
270
+ const proposalId = setupProposal()
271
+ db.prepare("UPDATE proposals SET status = 'review', session_id = 'sess-ci1' WHERE id = ?").run(proposalId)
272
+
273
+ const child = createMockChildProcess()
274
+ vi.mocked(mockSpawn).mockReturnValue(child as any)
275
+
276
+ const issuePromise = pm.createIssue(proposalId)
277
+ pushLine(child, assistantEvent('I created the issue.\nhttps://github.com/owner/repo/issues/99'))
278
+ pushLine(child, resultEvent('sess-ci2'))
279
+ await finishProcess(child, 0)
280
+ await issuePromise
281
+
282
+ const issueMsgs = getBroadcastedByType(broadcast, 'proposal_issue_created')
283
+ expect(issueMsgs).toHaveLength(1)
284
+ expect(issueMsgs[0].proposalId).toBe(proposalId)
285
+ expect(issueMsgs[0].issueUrl).toBe('https://github.com/owner/repo/issues/99')
286
+ })
287
+
288
+ it('broadcasts proposal_error if no GitHub URL found in response', async () => {
289
+ const proposalId = setupProposal()
290
+ db.prepare("UPDATE proposals SET status = 'review', session_id = 'sess-ci3' WHERE id = ?").run(proposalId)
291
+
292
+ const child = createMockChildProcess()
293
+ vi.mocked(mockSpawn).mockReturnValue(child as any)
294
+
295
+ const issuePromise = pm.createIssue(proposalId)
296
+ pushLine(child, assistantEvent('I could not create the issue. GitHub CLI not found.'))
297
+ pushLine(child, resultEvent('sess-ci4'))
298
+ await finishProcess(child, 0)
299
+ await issuePromise
300
+
301
+ const errorMsgs = getBroadcastedByType(broadcast, 'proposal_error')
302
+ expect(errorMsgs).toHaveLength(1)
303
+ expect(errorMsgs[0].proposalId).toBe(proposalId)
304
+ })
305
+
306
+ it('updates proposal status to created when URL found', async () => {
307
+ const proposalId = setupProposal()
308
+ db.prepare("UPDATE proposals SET status = 'review', session_id = 'sess-ci5' WHERE id = ?").run(proposalId)
309
+
310
+ const child = createMockChildProcess()
311
+ vi.mocked(mockSpawn).mockReturnValue(child as any)
312
+
313
+ const issuePromise = pm.createIssue(proposalId)
314
+ pushLine(child, assistantEvent('Done. https://github.com/owner/repo/issues/123'))
315
+ pushLine(child, resultEvent('sess-ci6'))
316
+ await finishProcess(child, 0)
317
+ await issuePromise
318
+
319
+ const row = getProposal(db, proposalId)!
320
+ expect(row.status).toBe('created')
321
+ expect(row.issue_url).toBe('https://github.com/owner/repo/issues/123')
322
+ })
323
+ })
324
+
325
+ // ─── cancel ───────────────────────────────────────────────────────────────
326
+
327
+ describe('cancel', () => {
328
+ it('calls treeKill with SIGTERM on active process', async () => {
329
+ const proposalId = setupProposal()
330
+ const child = createMockChildProcess()
331
+ vi.mocked(mockSpawn).mockReturnValue(child as any)
332
+
333
+ // Start exploration to create an active process
334
+ const explorePromise = pm.startExploration(proposalId, 'Add dark mode')
335
+
336
+ pm.cancel(proposalId)
337
+
338
+ expect(vi.mocked(treeKill)).toHaveBeenCalledWith(child.pid, 'SIGTERM')
339
+
340
+ // Let the process close to avoid open handles
341
+ await finishProcess(child, 1)
342
+ await explorePromise
343
+ })
344
+
345
+ it('updates proposal status to cancelled', () => {
346
+ const proposalId = setupProposal()
347
+
348
+ pm.cancel(proposalId)
349
+
350
+ const row = getProposal(db, proposalId)!
351
+ expect(row.status).toBe('cancelled')
352
+ })
353
+
354
+ it('broadcasts proposal_error with error: cancelled', () => {
355
+ const proposalId = setupProposal()
356
+
357
+ pm.cancel(proposalId)
358
+
359
+ const errorMsgs = getBroadcastedByType(broadcast, 'proposal_error')
360
+ expect(errorMsgs).toHaveLength(1)
361
+ expect(errorMsgs[0].error).toBe('cancelled')
362
+ })
363
+
364
+ it('does nothing if no active process (cancel still updates DB)', () => {
365
+ const proposalId = setupProposal()
366
+
367
+ pm.cancel(proposalId)
368
+
369
+ expect(vi.mocked(treeKill)).not.toHaveBeenCalled()
370
+ // DB update and broadcast still happen
371
+ expect(getProposal(db, proposalId)!.status).toBe('cancelled')
372
+ })
373
+ })
374
+
375
+ // ─── isActive ─────────────────────────────────────────────────────────────
376
+
377
+ describe('isActive', () => {
378
+ it('returns false before exploration starts', () => {
379
+ const proposalId = setupProposal()
380
+ expect(pm.isActive(proposalId)).toBe(false)
381
+ })
382
+
383
+ it('returns true while exploration is running', () => {
384
+ const proposalId = setupProposal()
385
+ const child = createMockChildProcess()
386
+ vi.mocked(mockSpawn).mockReturnValue(child as any)
387
+
388
+ pm.startExploration(proposalId, 'Add dark mode')
389
+ expect(pm.isActive(proposalId)).toBe(true)
390
+
391
+ // Cleanup
392
+ child.stdout.push(null)
393
+ child.emit('close', 0)
394
+ })
395
+
396
+ it('returns false after exploration completes', async () => {
397
+ const proposalId = setupProposal()
398
+ const child = createMockChildProcess()
399
+ vi.mocked(mockSpawn).mockReturnValue(child as any)
400
+
401
+ const explorePromise = pm.startExploration(proposalId, 'Add dark mode')
402
+ pushLine(child, assistantEvent('Content'))
403
+ pushLine(child, resultEvent('sess-x'))
404
+ await finishProcess(child, 0)
405
+ await explorePromise
406
+
407
+ expect(pm.isActive(proposalId)).toBe(false)
408
+ })
409
+ })
410
+ })
@@ -0,0 +1,285 @@
1
+ import { spawn, ChildProcess } from 'child_process'
2
+ import { createInterface } from 'readline'
3
+ import treeKill from 'tree-kill'
4
+ import type { WsMessage } from './types'
5
+ import type { DbInstance } from './db'
6
+ import {
7
+ getProposal,
8
+ updateProposal,
9
+ } from './db'
10
+ import { resolveCommand } from './command-resolver'
11
+
12
+ // ─── ProposalManager ──────────────────────────────────────────────────────────
13
+
14
+ export class ProposalManager {
15
+ private _broadcast: (msg: WsMessage) => void
16
+ private _db: DbInstance
17
+ private _cwd: string
18
+ private _activeProcesses: Map<string, ChildProcess>
19
+ private _buffers: Map<string, string>
20
+
21
+ constructor(broadcast: (msg: WsMessage) => void, db: DbInstance, cwd: string) {
22
+ this._broadcast = broadcast
23
+ this._db = db
24
+ this._cwd = cwd
25
+ this._activeProcesses = new Map()
26
+ this._buffers = new Map()
27
+ }
28
+
29
+ isActive(proposalId: string): boolean {
30
+ return this._activeProcesses.has(proposalId)
31
+ }
32
+
33
+ async startExploration(proposalId: string, idea: string): Promise<void> {
34
+ const proposal = getProposal(this._db, proposalId)
35
+ if (!proposal) {
36
+ this._broadcastError(proposalId, 'Proposal not found')
37
+ return
38
+ }
39
+
40
+ // Resolve the command file — error if not installed
41
+ const rawCommand = `/sr:propose-feature ${idea}`
42
+ const prompt = resolveCommand(rawCommand, this._cwd)
43
+ if (prompt === rawCommand) {
44
+ updateProposal(this._db, proposalId, { status: 'cancelled' })
45
+ this._broadcastError(proposalId, 'This project does not have the /sr:propose-feature command installed. Run "npx specrails" to update.')
46
+ return
47
+ }
48
+
49
+ updateProposal(this._db, proposalId, { status: 'exploring' })
50
+
51
+ const args = [
52
+ '--dangerously-skip-permissions',
53
+ '--output-format', 'stream-json',
54
+ '--verbose',
55
+ '-p', prompt,
56
+ ]
57
+
58
+ await this._runProcess(proposalId, args, (fullText, sessionId) => {
59
+ updateProposal(this._db, proposalId, {
60
+ status: 'review',
61
+ result_markdown: fullText,
62
+ ...(sessionId ? { session_id: sessionId } : {}),
63
+ })
64
+ this._broadcast({
65
+ type: 'proposal_ready',
66
+ projectId: '',
67
+ proposalId,
68
+ markdown: fullText,
69
+ timestamp: new Date().toISOString(),
70
+ })
71
+ }, () => {
72
+ updateProposal(this._db, proposalId, { status: 'input' })
73
+ this._broadcastError(proposalId, 'Exploration failed')
74
+ })
75
+ }
76
+
77
+ async sendRefinement(proposalId: string, feedback: string): Promise<void> {
78
+ const proposal = getProposal(this._db, proposalId)
79
+ if (!proposal) {
80
+ this._broadcastError(proposalId, 'Proposal not found')
81
+ return
82
+ }
83
+
84
+ if (!proposal.session_id) {
85
+ this._broadcastError(proposalId, 'No session available for refinement')
86
+ return
87
+ }
88
+
89
+ updateProposal(this._db, proposalId, { status: 'refining' })
90
+
91
+ const args = [
92
+ '--dangerously-skip-permissions',
93
+ '--output-format', 'stream-json',
94
+ '--verbose',
95
+ '--resume', proposal.session_id,
96
+ '-p', feedback,
97
+ ]
98
+
99
+ await this._runProcess(proposalId, args, (fullText, sessionId) => {
100
+ updateProposal(this._db, proposalId, {
101
+ status: 'review',
102
+ result_markdown: fullText,
103
+ ...(sessionId ? { session_id: sessionId } : {}),
104
+ })
105
+ this._broadcast({
106
+ type: 'proposal_refined',
107
+ projectId: '',
108
+ proposalId,
109
+ markdown: fullText,
110
+ timestamp: new Date().toISOString(),
111
+ })
112
+ }, () => {
113
+ updateProposal(this._db, proposalId, { status: 'review' })
114
+ this._broadcastError(proposalId, 'Refinement failed')
115
+ })
116
+ }
117
+
118
+ async createIssue(proposalId: string): Promise<void> {
119
+ const proposal = getProposal(this._db, proposalId)
120
+ if (!proposal) {
121
+ this._broadcastError(proposalId, 'Proposal not found')
122
+ return
123
+ }
124
+
125
+ if (!proposal.session_id) {
126
+ this._broadcastError(proposalId, 'No session available for issue creation')
127
+ return
128
+ }
129
+
130
+ updateProposal(this._db, proposalId, { status: 'refining' })
131
+
132
+ const prompt =
133
+ "Based on the proposal above, create a GitHub Issue with the label 'user-proposed'. " +
134
+ "Output only the URL of the created issue on the last line of your response."
135
+
136
+ const args = [
137
+ '--dangerously-skip-permissions',
138
+ '--output-format', 'stream-json',
139
+ '--verbose',
140
+ '--resume', proposal.session_id,
141
+ '-p', prompt,
142
+ ]
143
+
144
+ await this._runProcess(proposalId, args, (fullText, sessionId) => {
145
+ const match = fullText.match(/https:\/\/github\.com\/[^\s]+\/issues\/\d+/)
146
+ const issueUrl = match ? match[0] : null
147
+
148
+ if (issueUrl) {
149
+ updateProposal(this._db, proposalId, {
150
+ status: 'created',
151
+ issue_url: issueUrl,
152
+ ...(sessionId ? { session_id: sessionId } : {}),
153
+ })
154
+ this._broadcast({
155
+ type: 'proposal_issue_created',
156
+ projectId: '',
157
+ proposalId,
158
+ issueUrl,
159
+ timestamp: new Date().toISOString(),
160
+ })
161
+ } else {
162
+ updateProposal(this._db, proposalId, { status: 'review' })
163
+ this._broadcastError(
164
+ proposalId,
165
+ 'Issue creation failed — GitHub CLI may not be available or not authenticated'
166
+ )
167
+ }
168
+ }, () => {
169
+ updateProposal(this._db, proposalId, { status: 'review' })
170
+ this._broadcastError(proposalId, 'Issue creation failed')
171
+ })
172
+ }
173
+
174
+ cancel(proposalId: string): void {
175
+ const child = this._activeProcesses.get(proposalId)
176
+ if (child?.pid) {
177
+ treeKill(child.pid, 'SIGTERM')
178
+ }
179
+ updateProposal(this._db, proposalId, { status: 'cancelled' })
180
+ this._broadcast({
181
+ type: 'proposal_error',
182
+ projectId: '',
183
+ proposalId,
184
+ error: 'cancelled',
185
+ timestamp: new Date().toISOString(),
186
+ })
187
+ }
188
+
189
+ // ─── Private helpers ───────────────────────────────────────────────────────
190
+
191
+ private async _runProcess(
192
+ proposalId: string,
193
+ args: string[],
194
+ onSuccess: (fullText: string, sessionId: string | null) => void,
195
+ onError: () => void
196
+ ): Promise<void> {
197
+ const child = spawn('claude', args, {
198
+ env: process.env,
199
+ shell: false,
200
+ stdio: ['ignore', 'pipe', 'pipe'],
201
+ cwd: this._cwd,
202
+ })
203
+
204
+ this._activeProcesses.set(proposalId, child)
205
+ this._buffers.set(proposalId, '')
206
+
207
+ let capturedSessionId: string | null = null
208
+
209
+ const stdoutReader = createInterface({ input: child.stdout!, crlfDelay: Infinity })
210
+
211
+ stdoutReader.on('line', (line) => {
212
+ let parsed: Record<string, unknown> | null = null
213
+ try { parsed = JSON.parse(line) } catch { /* skip non-JSON */ }
214
+ if (!parsed) return
215
+
216
+ const eventType = parsed.type as string
217
+
218
+ if (eventType === 'result') {
219
+ const sid = parsed.session_id as string | undefined
220
+ if (sid) capturedSessionId = sid
221
+ }
222
+
223
+ if (eventType === 'assistant') {
224
+ const msg = parsed.message as { content?: Array<{ type: string; text?: string; name?: string }> } | undefined
225
+ const blocks = msg?.content ?? []
226
+
227
+ // Extract text from text blocks (skip thinking blocks)
228
+ const texts = blocks
229
+ .filter((c) => c.type === 'text')
230
+ .map((c) => c.text ?? '')
231
+ const newText = texts.join('')
232
+ if (newText) {
233
+ const prev = this._buffers.get(proposalId) ?? ''
234
+ this._buffers.set(proposalId, prev + newText)
235
+ this._broadcast({
236
+ type: 'proposal_stream',
237
+ projectId: '',
238
+ proposalId,
239
+ delta: newText,
240
+ timestamp: new Date().toISOString(),
241
+ })
242
+ }
243
+
244
+ // Broadcast tool_use activity so the UI can show "reading codebase..."
245
+ for (const block of blocks) {
246
+ if (block.type === 'tool_use' && block.name) {
247
+ this._broadcast({
248
+ type: 'proposal_stream',
249
+ projectId: '',
250
+ proposalId,
251
+ delta: `<!--tool:${block.name}-->`,
252
+ timestamp: new Date().toISOString(),
253
+ })
254
+ }
255
+ }
256
+ }
257
+ })
258
+
259
+ return new Promise<void>((resolve) => {
260
+ child.on('close', (code) => {
261
+ const fullText = this._buffers.get(proposalId) ?? ''
262
+ this._activeProcesses.delete(proposalId)
263
+ this._buffers.delete(proposalId)
264
+
265
+ if (code === 0) {
266
+ onSuccess(fullText, capturedSessionId)
267
+ } else {
268
+ onError()
269
+ }
270
+
271
+ resolve()
272
+ })
273
+ })
274
+ }
275
+
276
+ private _broadcastError(proposalId: string, error: string): void {
277
+ this._broadcast({
278
+ type: 'proposal_error',
279
+ projectId: '',
280
+ proposalId,
281
+ error,
282
+ timestamp: new Date().toISOString(),
283
+ })
284
+ }
285
+ }