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.
- package/LICENSE +21 -0
- package/README.md +255 -0
- package/cli/dist/srm.js +895 -0
- package/client/dist/assets/index-BEc7DzgE.css +1 -0
- package/client/dist/assets/index-DoIYcnfd.js +486 -0
- package/client/dist/index.html +13 -0
- package/package.json +57 -0
- package/server/analytics.test.ts +166 -0
- package/server/analytics.ts +318 -0
- package/server/chat-manager.test.ts +216 -0
- package/server/chat-manager.ts +289 -0
- package/server/command-grid-logic.test.ts +480 -0
- package/server/command-resolver.test.ts +136 -0
- package/server/command-resolver.ts +29 -0
- package/server/config.test.ts +193 -0
- package/server/config.ts +321 -0
- package/server/db.test.ts +409 -0
- package/server/db.ts +514 -0
- package/server/hooks.test.ts +196 -0
- package/server/hooks.ts +117 -0
- package/server/hub-db.ts +141 -0
- package/server/hub-router.ts +137 -0
- package/server/index.test.ts +538 -0
- package/server/index.ts +539 -0
- package/server/project-registry.ts +130 -0
- package/server/project-router.ts +451 -0
- package/server/proposal-manager.test.ts +410 -0
- package/server/proposal-manager.ts +285 -0
- package/server/proposal-routes.test.ts +424 -0
- package/server/queue-manager.test.ts +400 -0
- package/server/queue-manager.ts +545 -0
- package/server/setup-manager.ts +526 -0
- package/server/types.ts +360 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
|
|
2
|
+
import { EventEmitter } from 'events'
|
|
3
|
+
import { Readable } from 'stream'
|
|
4
|
+
|
|
5
|
+
// Mock child_process before importing chat-manager
|
|
6
|
+
vi.mock('child_process', () => ({
|
|
7
|
+
spawn: vi.fn(),
|
|
8
|
+
execSync: vi.fn(),
|
|
9
|
+
}))
|
|
10
|
+
|
|
11
|
+
vi.mock('tree-kill', () => ({
|
|
12
|
+
default: vi.fn(),
|
|
13
|
+
}))
|
|
14
|
+
|
|
15
|
+
import { spawn as mockSpawn, execSync as mockExecSync } from 'child_process'
|
|
16
|
+
import treeKill from 'tree-kill'
|
|
17
|
+
import { ChatManager } from './chat-manager'
|
|
18
|
+
import { initDb, createConversation, getConversation } from './db'
|
|
19
|
+
import type { DbInstance } from './db'
|
|
20
|
+
|
|
21
|
+
function createMockChildProcess() {
|
|
22
|
+
const child = new EventEmitter() as any
|
|
23
|
+
child.stdout = new Readable({ read() {} })
|
|
24
|
+
child.stderr = new Readable({ read() {} })
|
|
25
|
+
child.pid = 42000
|
|
26
|
+
child.kill = vi.fn()
|
|
27
|
+
return child
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function pushLine(child: any, line: string) {
|
|
31
|
+
child.stdout.push(line + '\n')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function finishProcess(child: any, code: number): Promise<void> {
|
|
35
|
+
// Push EOF on stdout, then wait for readline to drain before emitting close.
|
|
36
|
+
// readline processes data asynchronously; setImmediate ensures all buffered
|
|
37
|
+
// line events have fired before the close handler runs.
|
|
38
|
+
return new Promise((resolve) => {
|
|
39
|
+
child.stdout.push(null)
|
|
40
|
+
setImmediate(() => {
|
|
41
|
+
child.emit('close', code)
|
|
42
|
+
resolve()
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function assistantEvent(text: string): string {
|
|
48
|
+
return JSON.stringify({
|
|
49
|
+
type: 'assistant',
|
|
50
|
+
message: { content: [{ type: 'text', text }] },
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function resultEvent(sessionId: string): string {
|
|
55
|
+
return JSON.stringify({ type: 'result', session_id: sessionId })
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function getBroadcastedByType(broadcast: ReturnType<typeof vi.fn>, type: string) {
|
|
59
|
+
return broadcast.mock.calls
|
|
60
|
+
.map((args) => args[0] as Record<string, unknown>)
|
|
61
|
+
.filter((msg) => msg.type === type)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const TEST_CONV_ID = 'conv-test-001'
|
|
65
|
+
|
|
66
|
+
describe('ChatManager', () => {
|
|
67
|
+
let db: DbInstance
|
|
68
|
+
let broadcast: ReturnType<typeof vi.fn>
|
|
69
|
+
let cm: ChatManager
|
|
70
|
+
|
|
71
|
+
beforeEach(() => {
|
|
72
|
+
vi.resetAllMocks()
|
|
73
|
+
vi.mocked(mockExecSync).mockReturnValue(Buffer.from('/usr/bin/claude'))
|
|
74
|
+
db = initDb(':memory:')
|
|
75
|
+
broadcast = vi.fn()
|
|
76
|
+
cm = new ChatManager(broadcast, db)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
afterEach(() => {
|
|
80
|
+
vi.restoreAllMocks()
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
function setupConversation(model = 'claude-sonnet-4-5'): string {
|
|
84
|
+
createConversation(db, { id: TEST_CONV_ID, model })
|
|
85
|
+
return TEST_CONV_ID
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ─── Test 1: sendMessage persists user message and triggers chat_stream + chat_done ─
|
|
89
|
+
|
|
90
|
+
it('sendMessage persists user message and triggers chat_stream + chat_done broadcasts', async () => {
|
|
91
|
+
const convId = setupConversation()
|
|
92
|
+
const child = createMockChildProcess()
|
|
93
|
+
vi.mocked(mockSpawn).mockReturnValue(child as any)
|
|
94
|
+
|
|
95
|
+
const sendPromise = cm.sendMessage(convId, 'Hello world')
|
|
96
|
+
|
|
97
|
+
pushLine(child, assistantEvent('Hello '))
|
|
98
|
+
pushLine(child, assistantEvent('back!'))
|
|
99
|
+
pushLine(child, resultEvent('sess-abc'))
|
|
100
|
+
await finishProcess(child, 0)
|
|
101
|
+
|
|
102
|
+
await sendPromise
|
|
103
|
+
|
|
104
|
+
const streamMsgs = getBroadcastedByType(broadcast, 'chat_stream')
|
|
105
|
+
expect(streamMsgs.length).toBeGreaterThan(0)
|
|
106
|
+
expect(streamMsgs[0].conversationId).toBe(convId)
|
|
107
|
+
expect(streamMsgs[0].delta).toBeTruthy()
|
|
108
|
+
|
|
109
|
+
const doneMsgs = getBroadcastedByType(broadcast, 'chat_done')
|
|
110
|
+
expect(doneMsgs).toHaveLength(1)
|
|
111
|
+
expect(doneMsgs[0].conversationId).toBe(convId)
|
|
112
|
+
expect(doneMsgs[0].fullText).toBe('Hello back!')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
// ─── Test 2: abort triggers chat_error { error: 'aborted' } ───────────────
|
|
116
|
+
|
|
117
|
+
it('abort triggers chat_error with aborted reason', async () => {
|
|
118
|
+
const convId = setupConversation()
|
|
119
|
+
const child = createMockChildProcess()
|
|
120
|
+
vi.mocked(mockSpawn).mockReturnValue(child as any)
|
|
121
|
+
|
|
122
|
+
const sendPromise = cm.sendMessage(convId, 'Do something')
|
|
123
|
+
|
|
124
|
+
expect(cm.isActive(convId)).toBe(true)
|
|
125
|
+
cm.abort(convId)
|
|
126
|
+
|
|
127
|
+
await finishProcess(child, 1)
|
|
128
|
+
await sendPromise
|
|
129
|
+
|
|
130
|
+
const errorMsgs = getBroadcastedByType(broadcast, 'chat_error')
|
|
131
|
+
expect(errorMsgs.length).toBeGreaterThan(0)
|
|
132
|
+
expect(errorMsgs[0].conversationId).toBe(convId)
|
|
133
|
+
expect(errorMsgs[0].error).toBe('aborted')
|
|
134
|
+
expect(vi.mocked(treeKill)).toHaveBeenCalledWith(child.pid, 'SIGTERM')
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
// ─── Test 3: :::command block triggers chat_command_proposal ──────────────
|
|
138
|
+
|
|
139
|
+
it(':::command block in response triggers chat_command_proposal broadcast', async () => {
|
|
140
|
+
const convId = setupConversation()
|
|
141
|
+
const child = createMockChildProcess()
|
|
142
|
+
vi.mocked(mockSpawn).mockReturnValue(child as any)
|
|
143
|
+
|
|
144
|
+
const sendPromise = cm.sendMessage(convId, 'What should I do?')
|
|
145
|
+
|
|
146
|
+
const responseWithCommand = 'You should run:\n:::command\n/sr:implement #5\n:::\nThis will help.'
|
|
147
|
+
pushLine(child, assistantEvent(responseWithCommand))
|
|
148
|
+
pushLine(child, resultEvent('sess-xyz'))
|
|
149
|
+
await finishProcess(child, 0)
|
|
150
|
+
|
|
151
|
+
await sendPromise
|
|
152
|
+
|
|
153
|
+
const proposalMsgs = getBroadcastedByType(broadcast, 'chat_command_proposal')
|
|
154
|
+
expect(proposalMsgs).toHaveLength(1)
|
|
155
|
+
expect(proposalMsgs[0].conversationId).toBe(convId)
|
|
156
|
+
expect(proposalMsgs[0].command).toBe('/sr:implement #5')
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
// ─── Test 4: duplicate :::command blocks not emitted twice ────────────────
|
|
160
|
+
|
|
161
|
+
it('duplicate :::command blocks in same response are not emitted twice', async () => {
|
|
162
|
+
const convId = setupConversation()
|
|
163
|
+
const child = createMockChildProcess()
|
|
164
|
+
vi.mocked(mockSpawn).mockReturnValue(child as any)
|
|
165
|
+
|
|
166
|
+
const sendPromise = cm.sendMessage(convId, 'Suggest something')
|
|
167
|
+
|
|
168
|
+
// Emit the same command twice across two chunks (buffer accumulates)
|
|
169
|
+
pushLine(child, assistantEvent(':::command\n/sr:implement #1\n:::'))
|
|
170
|
+
pushLine(child, assistantEvent(' and again :::command\n/sr:implement #1\n:::'))
|
|
171
|
+
pushLine(child, resultEvent('sess-dup'))
|
|
172
|
+
await finishProcess(child, 0)
|
|
173
|
+
|
|
174
|
+
await sendPromise
|
|
175
|
+
|
|
176
|
+
const proposalMsgs = getBroadcastedByType(broadcast, 'chat_command_proposal')
|
|
177
|
+
expect(proposalMsgs).toHaveLength(1)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
// ─── Test 5: session_id stored in DB after first turn ────────────────────
|
|
181
|
+
|
|
182
|
+
it('session_id is stored in DB after first turn completes', async () => {
|
|
183
|
+
const convId = setupConversation()
|
|
184
|
+
const child = createMockChildProcess()
|
|
185
|
+
vi.mocked(mockSpawn).mockReturnValue(child as any)
|
|
186
|
+
|
|
187
|
+
const sendPromise = cm.sendMessage(convId, 'Hello')
|
|
188
|
+
|
|
189
|
+
pushLine(child, assistantEvent('Hi there'))
|
|
190
|
+
pushLine(child, resultEvent('sess-stored'))
|
|
191
|
+
await finishProcess(child, 0)
|
|
192
|
+
|
|
193
|
+
await sendPromise
|
|
194
|
+
|
|
195
|
+
const conv = getConversation(db, convId)
|
|
196
|
+
expect(conv?.session_id).toBe('sess-stored')
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
// ─── Test 6: isActive returns true while running, false after close ───────
|
|
200
|
+
|
|
201
|
+
it('isActive returns true while process is running and false after close', async () => {
|
|
202
|
+
const convId = setupConversation()
|
|
203
|
+
const child = createMockChildProcess()
|
|
204
|
+
vi.mocked(mockSpawn).mockReturnValue(child as any)
|
|
205
|
+
|
|
206
|
+
const sendPromise = cm.sendMessage(convId, 'Are you active?')
|
|
207
|
+
expect(cm.isActive(convId)).toBe(true)
|
|
208
|
+
|
|
209
|
+
pushLine(child, assistantEvent('Yes'))
|
|
210
|
+
pushLine(child, resultEvent('sess-active'))
|
|
211
|
+
await finishProcess(child, 0)
|
|
212
|
+
|
|
213
|
+
await sendPromise
|
|
214
|
+
expect(cm.isActive(convId)).toBe(false)
|
|
215
|
+
})
|
|
216
|
+
})
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { spawn, execSync, 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 { getConversation, addMessage, updateConversation } from './db'
|
|
7
|
+
|
|
8
|
+
const SYSTEM_PROMPT =
|
|
9
|
+
'You are a project assistant with full access to this repository via Claude Code. ' +
|
|
10
|
+
'You can help answer questions about the codebase, explain SpecRails concepts, and suggest commands to run. ' +
|
|
11
|
+
'When you want to suggest a SpecRails command for the user to execute, wrap it in a command block like this: ' +
|
|
12
|
+
':::command\n/sr:implement #42\n::: ' +
|
|
13
|
+
'The user will be prompted to confirm before the command runs.'
|
|
14
|
+
|
|
15
|
+
function claudeOnPath(): boolean {
|
|
16
|
+
try {
|
|
17
|
+
execSync('which claude', { stdio: 'ignore' })
|
|
18
|
+
return true
|
|
19
|
+
} catch {
|
|
20
|
+
return false
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function extractTextFromEvent(event: Record<string, unknown>): string | null {
|
|
25
|
+
const type = event.type as string
|
|
26
|
+
if (type === 'assistant') {
|
|
27
|
+
const content = event.message as { content?: Array<{ type: string; text?: string }> } | undefined
|
|
28
|
+
const texts = (content?.content ?? [])
|
|
29
|
+
.filter((c) => c.type === 'text')
|
|
30
|
+
.map((c) => c.text ?? '')
|
|
31
|
+
return texts.join('') || null
|
|
32
|
+
}
|
|
33
|
+
return null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function extractCommandProposals(text: string): string[] {
|
|
37
|
+
const regex = /:::command\s*\n([\s\S]*?):::/g
|
|
38
|
+
const results: string[] = []
|
|
39
|
+
let match: RegExpExecArray | null
|
|
40
|
+
while ((match = regex.exec(text)) !== null) {
|
|
41
|
+
results.push(match[1].trim())
|
|
42
|
+
}
|
|
43
|
+
return results
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ─── ChatManager ──────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
export class ChatManager {
|
|
49
|
+
private _broadcast: (msg: WsMessage) => void
|
|
50
|
+
private _db: DbInstance
|
|
51
|
+
private _activeProcesses: Map<string, ChildProcess>
|
|
52
|
+
private _buffers: Map<string, string>
|
|
53
|
+
private _emittedProposals: Map<string, Set<string>>
|
|
54
|
+
private _abortingConversations: Set<string>
|
|
55
|
+
|
|
56
|
+
private _cwd: string | undefined
|
|
57
|
+
|
|
58
|
+
constructor(broadcast: (msg: WsMessage) => void, db: DbInstance, cwd?: string) {
|
|
59
|
+
this._broadcast = broadcast
|
|
60
|
+
this._db = db
|
|
61
|
+
this._cwd = cwd
|
|
62
|
+
this._activeProcesses = new Map()
|
|
63
|
+
this._buffers = new Map()
|
|
64
|
+
this._emittedProposals = new Map()
|
|
65
|
+
this._abortingConversations = new Set()
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
isActive(conversationId: string): boolean {
|
|
69
|
+
return this._activeProcesses.has(conversationId)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async sendMessage(conversationId: string, userText: string): Promise<void> {
|
|
73
|
+
if (this._activeProcesses.has(conversationId)) {
|
|
74
|
+
console.warn(`[ChatManager] conversation ${conversationId} already has an active stream`)
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (!claudeOnPath()) {
|
|
79
|
+
this._broadcast({
|
|
80
|
+
type: 'chat_error',
|
|
81
|
+
conversationId,
|
|
82
|
+
error: 'CLAUDE_NOT_FOUND',
|
|
83
|
+
timestamp: new Date().toISOString(),
|
|
84
|
+
})
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const conversation = getConversation(this._db, conversationId)
|
|
89
|
+
if (!conversation) {
|
|
90
|
+
console.warn(`[ChatManager] conversation ${conversationId} not found`)
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Check if this is turn 1 (session_id was null before this message)
|
|
95
|
+
const isFirstTurn = conversation.session_id === null
|
|
96
|
+
|
|
97
|
+
// Persist user message
|
|
98
|
+
addMessage(this._db, { conversation_id: conversationId, role: 'user', content: userText })
|
|
99
|
+
|
|
100
|
+
// Build spawn args
|
|
101
|
+
const args: string[] = [
|
|
102
|
+
'--model', conversation.model,
|
|
103
|
+
'--dangerously-skip-permissions',
|
|
104
|
+
'--output-format', 'stream-json',
|
|
105
|
+
'--verbose',
|
|
106
|
+
'--system-prompt', SYSTEM_PROMPT,
|
|
107
|
+
'-p', userText,
|
|
108
|
+
]
|
|
109
|
+
|
|
110
|
+
if (conversation.session_id) {
|
|
111
|
+
args.push('--resume', conversation.session_id)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const child = spawn('claude', args, {
|
|
115
|
+
env: process.env,
|
|
116
|
+
shell: false,
|
|
117
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
118
|
+
cwd: this._cwd,
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
this._activeProcesses.set(conversationId, child)
|
|
122
|
+
this._buffers.set(conversationId, '')
|
|
123
|
+
this._emittedProposals.set(conversationId, new Set())
|
|
124
|
+
|
|
125
|
+
let capturedSessionId: string | null = null
|
|
126
|
+
|
|
127
|
+
const stdoutReader = createInterface({ input: child.stdout!, crlfDelay: Infinity })
|
|
128
|
+
|
|
129
|
+
stdoutReader.on('line', (line) => {
|
|
130
|
+
let parsed: Record<string, unknown> | null = null
|
|
131
|
+
try { parsed = JSON.parse(line) } catch { /* skip non-JSON */ }
|
|
132
|
+
if (!parsed) return
|
|
133
|
+
|
|
134
|
+
const eventType = parsed.type as string
|
|
135
|
+
|
|
136
|
+
if (eventType === 'result') {
|
|
137
|
+
const sid = parsed.session_id as string | undefined
|
|
138
|
+
if (sid) capturedSessionId = sid
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const newText = extractTextFromEvent(parsed)
|
|
142
|
+
if (newText) {
|
|
143
|
+
const prev = this._buffers.get(conversationId) ?? ''
|
|
144
|
+
const updated = prev + newText
|
|
145
|
+
this._buffers.set(conversationId, updated)
|
|
146
|
+
|
|
147
|
+
this._broadcast({
|
|
148
|
+
type: 'chat_stream',
|
|
149
|
+
conversationId,
|
|
150
|
+
delta: newText,
|
|
151
|
+
timestamp: new Date().toISOString(),
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
// Check for new command proposals
|
|
155
|
+
const proposals = extractCommandProposals(updated)
|
|
156
|
+
const emitted = this._emittedProposals.get(conversationId)
|
|
157
|
+
if (emitted) {
|
|
158
|
+
for (const proposal of proposals) {
|
|
159
|
+
if (!emitted.has(proposal)) {
|
|
160
|
+
emitted.add(proposal)
|
|
161
|
+
this._broadcast({
|
|
162
|
+
type: 'chat_command_proposal',
|
|
163
|
+
conversationId,
|
|
164
|
+
command: proposal,
|
|
165
|
+
timestamp: new Date().toISOString(),
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
return new Promise<void>((resolve) => {
|
|
174
|
+
child.on('close', (code) => {
|
|
175
|
+
const fullText = this._buffers.get(conversationId) ?? ''
|
|
176
|
+
const wasAborting = this._abortingConversations.has(conversationId)
|
|
177
|
+
|
|
178
|
+
// Clean up tracking state
|
|
179
|
+
this._activeProcesses.delete(conversationId)
|
|
180
|
+
this._buffers.delete(conversationId)
|
|
181
|
+
this._emittedProposals.delete(conversationId)
|
|
182
|
+
this._abortingConversations.delete(conversationId)
|
|
183
|
+
|
|
184
|
+
if (wasAborting) {
|
|
185
|
+
// abort already emitted chat_error
|
|
186
|
+
resolve()
|
|
187
|
+
return
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (code === 0) {
|
|
191
|
+
// Persist assistant message
|
|
192
|
+
if (fullText) {
|
|
193
|
+
addMessage(this._db, { conversation_id: conversationId, role: 'assistant', content: fullText })
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Update session_id
|
|
197
|
+
if (capturedSessionId) {
|
|
198
|
+
updateConversation(this._db, conversationId, { session_id: capturedSessionId })
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
this._broadcast({
|
|
202
|
+
type: 'chat_done',
|
|
203
|
+
conversationId,
|
|
204
|
+
fullText,
|
|
205
|
+
timestamp: new Date().toISOString(),
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
// Auto-title on first turn
|
|
209
|
+
if (isFirstTurn && fullText) {
|
|
210
|
+
this._autoTitle(conversationId, userText, fullText)
|
|
211
|
+
}
|
|
212
|
+
} else {
|
|
213
|
+
this._broadcast({
|
|
214
|
+
type: 'chat_error',
|
|
215
|
+
conversationId,
|
|
216
|
+
error: `Process exited with code ${code ?? 'unknown'}`,
|
|
217
|
+
timestamp: new Date().toISOString(),
|
|
218
|
+
})
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
resolve()
|
|
222
|
+
})
|
|
223
|
+
})
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
abort(conversationId: string): void {
|
|
227
|
+
const child = this._activeProcesses.get(conversationId)
|
|
228
|
+
if (!child || !child.pid) return
|
|
229
|
+
|
|
230
|
+
this._abortingConversations.add(conversationId)
|
|
231
|
+
treeKill(child.pid, 'SIGTERM')
|
|
232
|
+
|
|
233
|
+
this._broadcast({
|
|
234
|
+
type: 'chat_error',
|
|
235
|
+
conversationId,
|
|
236
|
+
error: 'aborted',
|
|
237
|
+
timestamp: new Date().toISOString(),
|
|
238
|
+
})
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private _autoTitle(conversationId: string, firstUserMsg: string, firstResponse: string): void {
|
|
242
|
+
try {
|
|
243
|
+
const titlePrompt =
|
|
244
|
+
`Generate a 4-6 word title for this conversation. Output ONLY the title text, no quotes or punctuation.\n\n` +
|
|
245
|
+
`User: ${firstUserMsg.slice(0, 200)}\nAssistant: ${firstResponse.slice(0, 300)}`
|
|
246
|
+
|
|
247
|
+
const child = spawn('claude', [
|
|
248
|
+
'--dangerously-skip-permissions',
|
|
249
|
+
'--output-format', 'stream-json',
|
|
250
|
+
'--verbose',
|
|
251
|
+
'-p', titlePrompt,
|
|
252
|
+
], {
|
|
253
|
+
env: process.env,
|
|
254
|
+
shell: false,
|
|
255
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
256
|
+
cwd: this._cwd,
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
let titleText = ''
|
|
260
|
+
const reader = createInterface({ input: child.stdout!, crlfDelay: Infinity })
|
|
261
|
+
|
|
262
|
+
reader.on('line', (line) => {
|
|
263
|
+
let parsed: Record<string, unknown> | null = null
|
|
264
|
+
try { parsed = JSON.parse(line) } catch { return }
|
|
265
|
+
if (!parsed) return
|
|
266
|
+
|
|
267
|
+
// Take only the first assistant event's text
|
|
268
|
+
if (!titleText) {
|
|
269
|
+
const text = extractTextFromEvent(parsed)
|
|
270
|
+
if (text) titleText = text.trim()
|
|
271
|
+
}
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
child.on('close', (code) => {
|
|
275
|
+
if (code === 0 && titleText) {
|
|
276
|
+
updateConversation(this._db, conversationId, { title: titleText })
|
|
277
|
+
this._broadcast({
|
|
278
|
+
type: 'chat_title_update',
|
|
279
|
+
conversationId,
|
|
280
|
+
title: titleText,
|
|
281
|
+
timestamp: new Date().toISOString(),
|
|
282
|
+
})
|
|
283
|
+
}
|
|
284
|
+
})
|
|
285
|
+
} catch {
|
|
286
|
+
// auto-title is fire-and-forget; failure is silent
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|