@shawnstack/quickforge 1.4.1 → 1.5.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/README.md +12 -12
- package/bin/quickforge.mjs +9 -0
- package/dist/assets/AgentProfilesPage-DUmXUxjA.js +1 -0
- package/dist/assets/ChatPanelHost-Syx0SSLe.js +242 -0
- package/dist/assets/PluginsPage-kiBq0gOT.js +1 -0
- package/dist/assets/ScheduledTasksPage-Dw4-tgp9.js +2 -0
- package/dist/assets/SharedConversationPage-CaE9bNb9.js +1 -0
- package/dist/assets/TerminalDock-BYJcp8Ts.js +2 -0
- package/dist/assets/WorkspaceInspector-Bzmv8Cvi.js +3 -0
- package/dist/assets/WorkspaceReaderDialog-BJo_KEWi.js +1 -0
- package/dist/assets/diff-line-counts-BZoYp5ai.js +10 -0
- package/dist/assets/icons-47L5YLKz.js +1 -0
- package/dist/assets/index-CqfScETb.js +1200 -0
- package/dist/assets/index-DzkBgHZf.css +3 -0
- package/dist/assets/{monaco-evITXh-m.js → monaco-CGq6uVF1.js} +1 -1
- package/dist/assets/{react-vendor-Mthyt1p4.js → react-vendor-DunfCFfp.js} +1 -1
- package/dist/favicon.svg +16 -1
- package/dist/index.html +5 -5
- package/dist/manifest.webmanifest +30 -30
- package/package.json +3 -2
- package/server/acp/server.mjs +921 -0
- package/server/agent-manager.mjs +198 -32
- package/server/agent-profile-files.mjs +179 -0
- package/server/agent-profiles.mjs +59 -5
- package/server/auto-compaction.mjs +82 -39
- package/server/channels/process-channel.mjs +278 -0
- package/server/channels/providers/wechat.mjs +271 -0
- package/server/channels/registry.mjs +58 -0
- package/server/custom-commands.mjs +13 -1
- package/server/frontmatter.mjs +167 -0
- package/server/index.mjs +52 -3
- package/server/project-config.mjs +43 -6
- package/server/routes/agent-profiles.mjs +6 -2
- package/server/routes/agent.mjs +12 -1
- package/server/routes/channels.mjs +145 -0
- package/server/routes/models.mjs +68 -0
- package/server/routes/project.mjs +2 -2
- package/server/routes/scheduled-tasks.mjs +6 -5
- package/server/routes/storage.mjs +4 -2
- package/server/routes/system.mjs +27 -0
- package/server/routes/tools.mjs +17 -6
- package/server/routes/workspace.mjs +138 -0
- package/server/session-utils.mjs +10 -2
- package/server/storage.mjs +29 -2
- package/server/system-prompt.mjs +1 -0
- package/server/tools/definitions.mjs +18 -0
- package/server/tools/index.mjs +83 -0
- package/server/utils/package-update.mjs +156 -0
- package/dist/assets/AgentProfilesPage-CNK5PxA3.js +0 -1
- package/dist/assets/ChatPanelHost-FqPQwwMO.js +0 -217
- package/dist/assets/PluginsPage-BCu1Ept0.js +0 -1
- package/dist/assets/ScheduledTasksPage-Bx04rjui.js +0 -2
- package/dist/assets/SharedConversationPage-55vX9sqe.js +0 -1
- package/dist/assets/TerminalDock-DLN_pLkJ.js +0 -2
- package/dist/assets/WorkspaceInspector-DoemHHnY.js +0 -3
- package/dist/assets/WorkspaceReaderDialog-C6xUHBCw.js +0 -6
- package/dist/assets/icons-BWtivFsx.js +0 -1
- package/dist/assets/index-CxOHP41X.css +0 -3
- package/dist/assets/index-Dcf73EL8.js +0 -895
|
@@ -2,6 +2,8 @@ import { randomUUID } from 'node:crypto'
|
|
|
2
2
|
import { readStore, atomicUpdate } from './storage.mjs'
|
|
3
3
|
import { subagentDefinitions } from './subagents.mjs'
|
|
4
4
|
import { workspaceTools } from './tools/definitions.mjs'
|
|
5
|
+
import { defaultGlobalWorkspaceContext, projectContextFromId } from './project-config.mjs'
|
|
6
|
+
import { loadFileAgentProfiles } from './agent-profile-files.mjs'
|
|
5
7
|
|
|
6
8
|
const STORE = 'custom-agents'
|
|
7
9
|
const RESERVED_NAMES = new Set(subagentDefinitions.map((definition) => definition.name))
|
|
@@ -56,6 +58,9 @@ function builtinProfileFromSubagent(definition) {
|
|
|
56
58
|
maxToolCalls: definition.maxToolCalls || DEFAULT_MAX_TOOL_CALLS,
|
|
57
59
|
enabledAsSubagent: true,
|
|
58
60
|
builtin: true,
|
|
61
|
+
source: 'builtin',
|
|
62
|
+
readonly: true,
|
|
63
|
+
allowFileMutations: definition.allowFileMutations === true,
|
|
59
64
|
createdAt: 'builtin',
|
|
60
65
|
updatedAt: 'builtin',
|
|
61
66
|
}
|
|
@@ -92,6 +97,9 @@ function normalizeProfileInput(input, existing = null, { creating = false } = {}
|
|
|
92
97
|
maxToolCalls: normalizeOptionalPositiveInteger(input?.maxToolCalls ?? existing?.maxToolCalls, DEFAULT_MAX_TOOL_CALLS, 300),
|
|
93
98
|
enabledAsSubagent: input?.enabledAsSubagent === undefined ? Boolean(existing?.enabledAsSubagent ?? true) : input.enabledAsSubagent === true,
|
|
94
99
|
builtin: false,
|
|
100
|
+
source: 'store',
|
|
101
|
+
readonly: false,
|
|
102
|
+
allowFileMutations: allowedTools.some((toolName) => toolName === 'write_file' || toolName === 'edit_file'),
|
|
95
103
|
createdAt: existing?.createdAt || now,
|
|
96
104
|
updatedAt: now,
|
|
97
105
|
}
|
|
@@ -102,20 +110,61 @@ async function readCustomAgentMap() {
|
|
|
102
110
|
return data && typeof data === 'object' ? data : {}
|
|
103
111
|
}
|
|
104
112
|
|
|
113
|
+
async function resolveWorkspaceRoot(options = {}) {
|
|
114
|
+
if (options.workspaceRoot) return options.workspaceRoot
|
|
115
|
+
if (options.projectId) {
|
|
116
|
+
try {
|
|
117
|
+
return (await projectContextFromId(options.projectId))?.workspaceRoot || null
|
|
118
|
+
} catch {
|
|
119
|
+
return null
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return defaultGlobalWorkspaceContext()?.workspaceRoot || null
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function mergeProfiles({ builtin = [], file = [], custom = [] }) {
|
|
126
|
+
const reservedNames = new Set(builtin.map((profile) => profile.name))
|
|
127
|
+
const byName = new Map()
|
|
128
|
+
|
|
129
|
+
for (const profile of builtin) {
|
|
130
|
+
byName.set(profile.name, profile)
|
|
131
|
+
}
|
|
132
|
+
for (const profile of file) {
|
|
133
|
+
if (!profile?.name || reservedNames.has(profile.name)) continue
|
|
134
|
+
byName.set(profile.name, profile)
|
|
135
|
+
}
|
|
136
|
+
for (const profile of custom) {
|
|
137
|
+
if (!profile?.id) continue
|
|
138
|
+
if (!reservedNames.has(profile.name) && !byName.has(profile.name)) byName.set(profile.name, profile)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return [...byName.values()].sort((a, b) => {
|
|
142
|
+
if (a.builtin && !b.builtin) return -1
|
|
143
|
+
if (!a.builtin && b.builtin) return 1
|
|
144
|
+
return a.name.localeCompare(b.name)
|
|
145
|
+
})
|
|
146
|
+
}
|
|
147
|
+
|
|
105
148
|
export async function listAgentProfiles(options = {}) {
|
|
106
149
|
const custom = Object.values(await readCustomAgentMap())
|
|
107
|
-
const
|
|
150
|
+
const workspaceRoot = await resolveWorkspaceRoot(options)
|
|
151
|
+
const file = await loadFileAgentProfiles(workspaceRoot, { reservedNames: RESERVED_NAMES })
|
|
152
|
+
const profiles = mergeProfiles({ builtin: listBuiltinAgentProfiles(), file, custom })
|
|
108
153
|
return options.includeDisabled ? profiles : profiles.filter((profile) => profile.enabledAsSubagent || profile.builtin || profile.enabledAsSubagent === false)
|
|
109
154
|
}
|
|
110
155
|
|
|
111
|
-
export async function listSubagentProfiles() {
|
|
112
|
-
return (await listAgentProfiles({ includeDisabled: true })).filter((profile) => profile.enabledAsSubagent)
|
|
156
|
+
export async function listSubagentProfiles(options = {}) {
|
|
157
|
+
return (await listAgentProfiles({ ...options, includeDisabled: true })).filter((profile) => profile.enabledAsSubagent)
|
|
113
158
|
}
|
|
114
159
|
|
|
115
|
-
export async function getAgentProfile(idOrName) {
|
|
160
|
+
export async function getAgentProfile(idOrName, options = {}) {
|
|
116
161
|
const key = String(idOrName || '').trim().toLowerCase()
|
|
117
162
|
if (!key) return null
|
|
118
|
-
|
|
163
|
+
const profiles = await listAgentProfiles({ ...options, includeDisabled: true })
|
|
164
|
+
const byName = profiles.find((profile) => profile.name === key)
|
|
165
|
+
if (byName) return byName
|
|
166
|
+
const custom = Object.values(await readCustomAgentMap())
|
|
167
|
+
return custom.find((profile) => profile?.id === key) || profiles.find((profile) => profile.id === key) || null
|
|
119
168
|
}
|
|
120
169
|
|
|
121
170
|
export async function createCustomAgentProfile(input) {
|
|
@@ -167,7 +216,12 @@ export function agentProfileSnapshot(profile) {
|
|
|
167
216
|
allowedTools: [...profile.allowedTools],
|
|
168
217
|
maxRuntimeMs: profile.maxRuntimeMs,
|
|
169
218
|
maxToolCalls: profile.maxToolCalls,
|
|
219
|
+
enabledAsSubagent: profile.enabledAsSubagent === true,
|
|
170
220
|
builtin: profile.builtin === true,
|
|
221
|
+
source: profile.source || (profile.builtin ? 'builtin' : 'store'),
|
|
222
|
+
readonly: profile.readonly === true || profile.builtin === true,
|
|
223
|
+
filePath: profile.filePath,
|
|
224
|
+
relativePath: profile.relativePath,
|
|
171
225
|
}
|
|
172
226
|
}
|
|
173
227
|
|
|
@@ -151,6 +151,76 @@ export function buildAutoCompactLoopMessages(session, messages) {
|
|
|
151
151
|
return [summaryMessage, ...source.slice(compactedUpToIndex)]
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
+
export async function compactSessionInPlace({
|
|
155
|
+
session,
|
|
156
|
+
messages,
|
|
157
|
+
keepRecentTurns = DEFAULT_AUTO_COMPACT_SETTINGS.keepRecentTurns,
|
|
158
|
+
minSourceChars = DEFAULT_AUTO_COMPACT_SETTINGS.minSourceChars,
|
|
159
|
+
usage,
|
|
160
|
+
thresholdPercent,
|
|
161
|
+
emitSessionEvent,
|
|
162
|
+
persistSession,
|
|
163
|
+
reason = 'manual_compact',
|
|
164
|
+
summaryIntro = 'The previous conversation has been compacted. Treat the following summary as the authoritative replacement for earlier history. If information is missing, ask for clarification instead of guessing.',
|
|
165
|
+
onBeforePersist,
|
|
166
|
+
}) {
|
|
167
|
+
const source = Array.isArray(messages) ? messages : []
|
|
168
|
+
const normalizedKeepRecentTurns = clampNumber(keepRecentTurns, DEFAULT_AUTO_COMPACT_SETTINGS.keepRecentTurns, 1, 20)
|
|
169
|
+
const normalizedMinSourceChars = clampNumber(minSourceChars, DEFAULT_AUTO_COMPACT_SETTINGS.minSourceChars, 0, 200000)
|
|
170
|
+
const tailStart = tailStartForRecentTurns(source, normalizedKeepRecentTurns)
|
|
171
|
+
const sourceMessages = buildCompactionSourceMessages(session, source, tailStart)
|
|
172
|
+
if (sourceMessages.length < 2 || estimateMessagesChars(sourceMessages) < normalizedMinSourceChars) {
|
|
173
|
+
return { compacted: false, usage, reason: 'not_enough_history' }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const result = await compactConversation({
|
|
177
|
+
messages: sourceMessages,
|
|
178
|
+
model: session.model,
|
|
179
|
+
thinkingLevel: session.thinkingLevel,
|
|
180
|
+
getApiKey: session.getApiKey,
|
|
181
|
+
keepTurns: 0,
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
if (result.skipped) return { compacted: false, usage, reason: result.reason || 'skipped' }
|
|
185
|
+
|
|
186
|
+
await saveCompactBackup(session.sessionId, sourceMessages)
|
|
187
|
+
const summaryMessage = userTextMessage([
|
|
188
|
+
summaryIntro,
|
|
189
|
+
'',
|
|
190
|
+
'<compact_summary>',
|
|
191
|
+
result.summary,
|
|
192
|
+
'</compact_summary>',
|
|
193
|
+
].join('\n'))
|
|
194
|
+
session.contextCompaction = {
|
|
195
|
+
summaryMessage,
|
|
196
|
+
compactedUpToIndex: tailStart,
|
|
197
|
+
compactedAt: new Date().toISOString(),
|
|
198
|
+
keepRecentTurns: normalizedKeepRecentTurns,
|
|
199
|
+
sourceMessageCount: source.length,
|
|
200
|
+
usageBefore: usage,
|
|
201
|
+
thresholdPercent,
|
|
202
|
+
}
|
|
203
|
+
onBeforePersist?.({ result, sourceMessages, tailStart, summaryMessage })
|
|
204
|
+
await persistSession?.(session)
|
|
205
|
+
const contextUsage = estimateSessionContextUsage(session, source)
|
|
206
|
+
emitSessionEvent?.(session, {
|
|
207
|
+
type: 'auto_compact_completed',
|
|
208
|
+
reason,
|
|
209
|
+
usage,
|
|
210
|
+
thresholdPercent,
|
|
211
|
+
contextCompaction: session.contextCompaction,
|
|
212
|
+
contextUsage,
|
|
213
|
+
})
|
|
214
|
+
emitSessionEvent?.(session, {
|
|
215
|
+
type: 'messages_replaced',
|
|
216
|
+
reason,
|
|
217
|
+
messages: source,
|
|
218
|
+
contextCompaction: session.contextCompaction,
|
|
219
|
+
contextUsage,
|
|
220
|
+
})
|
|
221
|
+
return { compacted: true, usage, result, sourceMessages, tailStart }
|
|
222
|
+
}
|
|
223
|
+
|
|
154
224
|
export function estimateSessionContextUsage(session, messages = session?.agent?.state?.messages ?? []) {
|
|
155
225
|
if (!session?.agent?.state) return null
|
|
156
226
|
const sourceMessages = Array.isArray(messages) ? messages : []
|
|
@@ -274,50 +344,23 @@ export async function maybeAutoCompactSession({ session, messages, signal, emitS
|
|
|
274
344
|
|
|
275
345
|
session.autoCompacting = true
|
|
276
346
|
try {
|
|
277
|
-
const result = await
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
thinkingLevel: session.thinkingLevel,
|
|
281
|
-
getApiKey: session.getApiKey,
|
|
282
|
-
keepTurns: 0,
|
|
283
|
-
})
|
|
284
|
-
|
|
285
|
-
if (result.skipped) return { compacted: false, usage, reason: result.reason || 'skipped' }
|
|
286
|
-
|
|
287
|
-
await saveCompactBackup(session.sessionId, sourceMessages)
|
|
288
|
-
const summaryMessage = userTextMessage([
|
|
289
|
-
'The previous conversation has been automatically compacted. Treat the following summary as the authoritative replacement for earlier history. If information is missing, ask for clarification instead of guessing.',
|
|
290
|
-
'',
|
|
291
|
-
'<compact_summary>',
|
|
292
|
-
result.summary,
|
|
293
|
-
'</compact_summary>',
|
|
294
|
-
].join('\n'))
|
|
295
|
-
session.contextCompaction = {
|
|
296
|
-
summaryMessage,
|
|
297
|
-
compactedUpToIndex: tailStart,
|
|
298
|
-
compactedAt: new Date().toISOString(),
|
|
347
|
+
const result = await compactSessionInPlace({
|
|
348
|
+
session,
|
|
349
|
+
messages,
|
|
299
350
|
keepRecentTurns: settings.keepRecentTurns,
|
|
300
|
-
|
|
301
|
-
usageBefore: usage,
|
|
302
|
-
thresholdPercent: settings.thresholdPercent,
|
|
303
|
-
}
|
|
304
|
-
clearAutoCompactRejected(session)
|
|
305
|
-
session.lastAutoCompactAt = now
|
|
306
|
-
await persistSession(session)
|
|
307
|
-
emitSessionEvent(session, {
|
|
308
|
-
type: 'auto_compact_completed',
|
|
351
|
+
minSourceChars: settings.minSourceChars,
|
|
309
352
|
usage,
|
|
310
353
|
thresholdPercent: settings.thresholdPercent,
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
})
|
|
314
|
-
emitSessionEvent(session, {
|
|
315
|
-
type: 'messages_replaced',
|
|
354
|
+
emitSessionEvent,
|
|
355
|
+
persistSession,
|
|
316
356
|
reason: 'auto_compact',
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
357
|
+
summaryIntro: 'The previous conversation has been automatically compacted. Treat the following summary as the authoritative replacement for earlier history. If information is missing, ask for clarification instead of guessing.',
|
|
358
|
+
onBeforePersist: () => {
|
|
359
|
+
clearAutoCompactRejected(session)
|
|
360
|
+
session.lastAutoCompactAt = now
|
|
361
|
+
},
|
|
320
362
|
})
|
|
363
|
+
if (!result.compacted) return result
|
|
321
364
|
return { compacted: true, usage }
|
|
322
365
|
} catch (error) {
|
|
323
366
|
logger?.warn?.(`Auto compact failed for session ${session.sessionId}:`, error?.message || error, { sessionId: session.sessionId })
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { EventEmitter } from 'node:events'
|
|
3
|
+
import { setTimeout as delay } from 'node:timers/promises'
|
|
4
|
+
|
|
5
|
+
const DEFAULT_LOG_LIMIT = 300
|
|
6
|
+
const STOP_TIMEOUT_MS = 5000
|
|
7
|
+
|
|
8
|
+
function nowIso() {
|
|
9
|
+
return new Date().toISOString()
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function normalizeChunk(chunk) {
|
|
13
|
+
return Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function stripAnsi(value) {
|
|
17
|
+
return String(value).replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/g, '')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function appendLineBuffer(buffer, text, onLine) {
|
|
21
|
+
const combined = buffer + text
|
|
22
|
+
const lines = combined.split(/\r?\n/)
|
|
23
|
+
const nextBuffer = lines.pop() || ''
|
|
24
|
+
for (const line of lines) onLine(line)
|
|
25
|
+
return nextBuffer
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function runDetachedCommand(command, args) {
|
|
29
|
+
return new Promise((resolve) => {
|
|
30
|
+
const child = spawn(command, args, { windowsHide: true, stdio: 'ignore' })
|
|
31
|
+
child.once('error', () => resolve(false))
|
|
32
|
+
child.once('exit', (code) => resolve(code === 0))
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function terminateProcess(child, signal = 'SIGTERM') {
|
|
37
|
+
if (!child?.pid) return false
|
|
38
|
+
if (process.platform === 'win32') {
|
|
39
|
+
return runDetachedCommand('taskkill.exe', ['/PID', String(child.pid), '/T', signal === 'SIGKILL' ? '/F' : undefined].filter(Boolean))
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
return process.kill(-child.pid, signal)
|
|
43
|
+
} catch {
|
|
44
|
+
try {
|
|
45
|
+
return child.kill(signal)
|
|
46
|
+
} catch {
|
|
47
|
+
return false
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class ProcessChannelProvider extends EventEmitter {
|
|
53
|
+
constructor(definition, options = {}) {
|
|
54
|
+
super()
|
|
55
|
+
this.definition = definition
|
|
56
|
+
this.logLimit = options.logLimit || DEFAULT_LOG_LIMIT
|
|
57
|
+
this.status = 'stopped'
|
|
58
|
+
this.process = null
|
|
59
|
+
this.pid = null
|
|
60
|
+
this.startedAt = null
|
|
61
|
+
this.stoppedAt = null
|
|
62
|
+
this.exitCode = null
|
|
63
|
+
this.exitSignal = null
|
|
64
|
+
this.error = null
|
|
65
|
+
this.logs = []
|
|
66
|
+
this.qrCodeUrl = null
|
|
67
|
+
this.qrCodeText = ''
|
|
68
|
+
this.activeAction = null
|
|
69
|
+
this.stopRequested = false
|
|
70
|
+
this.stdoutBuffer = ''
|
|
71
|
+
this.stderrBuffer = ''
|
|
72
|
+
this.startPromise = null
|
|
73
|
+
this.stopPromise = null
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
snapshot() {
|
|
77
|
+
return {
|
|
78
|
+
id: this.definition.id,
|
|
79
|
+
name: this.definition.name,
|
|
80
|
+
description: this.definition.description,
|
|
81
|
+
kind: this.definition.kind || 'process',
|
|
82
|
+
provider: this.definition.provider,
|
|
83
|
+
icon: this.definition.icon,
|
|
84
|
+
commandLabel: this.definition.commandLabel,
|
|
85
|
+
supportsWorkspaceSelection: this.definition.supportsWorkspaceSelection === true,
|
|
86
|
+
status: this.status,
|
|
87
|
+
pid: this.pid,
|
|
88
|
+
startedAt: this.startedAt,
|
|
89
|
+
stoppedAt: this.stoppedAt,
|
|
90
|
+
exitCode: this.exitCode,
|
|
91
|
+
exitSignal: this.exitSignal,
|
|
92
|
+
error: this.error,
|
|
93
|
+
logs: this.logs,
|
|
94
|
+
qrCodeUrl: this.qrCodeUrl,
|
|
95
|
+
qrCodeText: this.qrCodeText,
|
|
96
|
+
actions: this.definition.actions || [],
|
|
97
|
+
requirements: this.definition.requirements || [],
|
|
98
|
+
activeAction: this.activeAction,
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
emitEvent(type, payload = {}) {
|
|
103
|
+
const event = {
|
|
104
|
+
type,
|
|
105
|
+
channelId: this.definition.id,
|
|
106
|
+
timestamp: nowIso(),
|
|
107
|
+
...payload,
|
|
108
|
+
}
|
|
109
|
+
this.emit('event', event)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
setStatus(status, extra = {}) {
|
|
113
|
+
this.status = status
|
|
114
|
+
if (Object.prototype.hasOwnProperty.call(extra, 'error')) {
|
|
115
|
+
this.error = extra.error
|
|
116
|
+
}
|
|
117
|
+
this.emitEvent('status', { status, snapshot: this.snapshot() })
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
addLog(stream, text) {
|
|
121
|
+
if (!text) return
|
|
122
|
+
const entry = {
|
|
123
|
+
id: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
|
|
124
|
+
time: nowIso(),
|
|
125
|
+
stream,
|
|
126
|
+
text,
|
|
127
|
+
}
|
|
128
|
+
this.logs.push(entry)
|
|
129
|
+
if (this.logs.length > this.logLimit) {
|
|
130
|
+
this.logs.splice(0, this.logs.length - this.logLimit)
|
|
131
|
+
}
|
|
132
|
+
this.emitEvent('log', { log: entry })
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
addProcessText(stream, text) {
|
|
136
|
+
const cleanText = stripAnsi(text).replace(/\r/g, '\n')
|
|
137
|
+
if (!cleanText) return
|
|
138
|
+
this.addLog(stream, cleanText)
|
|
139
|
+
this.inspectOutput(cleanText, stream)
|
|
140
|
+
|
|
141
|
+
const lineHandler = (line) => this.inspectOutputLine(line, stream)
|
|
142
|
+
if (stream === 'stderr') {
|
|
143
|
+
this.stderrBuffer = appendLineBuffer(this.stderrBuffer, cleanText, lineHandler)
|
|
144
|
+
} else {
|
|
145
|
+
this.stdoutBuffer = appendLineBuffer(this.stdoutBuffer, cleanText, lineHandler)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
inspectOutput(_text, _stream) {
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
inspectOutputLine(_line, _stream) {
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
buildStartCommand() {
|
|
156
|
+
throw new Error('buildStartCommand() is not implemented')
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async beforeStart(_options = {}) {}
|
|
160
|
+
|
|
161
|
+
async start(options = {}) {
|
|
162
|
+
if (this.process) return this.snapshot()
|
|
163
|
+
if (this.startPromise) return this.startPromise
|
|
164
|
+
if (this.status === 'stopping') return this.snapshot()
|
|
165
|
+
|
|
166
|
+
this.startPromise = this.#startProcess(options)
|
|
167
|
+
try {
|
|
168
|
+
return await this.startPromise
|
|
169
|
+
} finally {
|
|
170
|
+
this.startPromise = null
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async #startProcess(options = {}) {
|
|
175
|
+
this.stopRequested = false
|
|
176
|
+
this.exitCode = null
|
|
177
|
+
this.exitSignal = null
|
|
178
|
+
this.error = null
|
|
179
|
+
this.qrCodeUrl = null
|
|
180
|
+
this.qrCodeText = ''
|
|
181
|
+
this.stdoutBuffer = ''
|
|
182
|
+
this.stderrBuffer = ''
|
|
183
|
+
this.startedAt = nowIso()
|
|
184
|
+
this.stoppedAt = null
|
|
185
|
+
this.setStatus('starting')
|
|
186
|
+
|
|
187
|
+
await this.beforeStart(options)
|
|
188
|
+
if (this.process) return this.snapshot()
|
|
189
|
+
|
|
190
|
+
const commandSpec = this.buildStartCommand(options)
|
|
191
|
+
|
|
192
|
+
const child = spawn(commandSpec.command, commandSpec.args || [], {
|
|
193
|
+
cwd: commandSpec.cwd,
|
|
194
|
+
env: commandSpec.env || process.env,
|
|
195
|
+
windowsHide: true,
|
|
196
|
+
detached: process.platform !== 'win32',
|
|
197
|
+
shell: commandSpec.shell === true,
|
|
198
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
this.process = child
|
|
202
|
+
this.pid = child.pid || null
|
|
203
|
+
this.emitEvent('status', { status: this.status, snapshot: this.snapshot() })
|
|
204
|
+
|
|
205
|
+
child.stdout?.on('data', (chunk) => this.addProcessText('stdout', normalizeChunk(chunk)))
|
|
206
|
+
child.stderr?.on('data', (chunk) => this.addProcessText('stderr', normalizeChunk(chunk)))
|
|
207
|
+
|
|
208
|
+
child.once('error', (error) => {
|
|
209
|
+
this.error = error.message || String(error)
|
|
210
|
+
this.addLog('stderr', this.error)
|
|
211
|
+
this.process = null
|
|
212
|
+
this.pid = null
|
|
213
|
+
this.stoppedAt = nowIso()
|
|
214
|
+
this.setStatus('error', { error: this.error })
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
child.once('exit', (code, signal) => {
|
|
218
|
+
this.process = null
|
|
219
|
+
this.pid = null
|
|
220
|
+
this.exitCode = code
|
|
221
|
+
this.exitSignal = signal
|
|
222
|
+
this.stoppedAt = nowIso()
|
|
223
|
+
if (this.stopRequested || code === 0) {
|
|
224
|
+
this.setStatus('stopped')
|
|
225
|
+
} else {
|
|
226
|
+
const reason = `Channel process exited with code ${code ?? 'null'}${signal ? ` signal ${signal}` : ''}`
|
|
227
|
+
this.error = reason
|
|
228
|
+
this.setStatus('error', { error: reason })
|
|
229
|
+
}
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
return this.snapshot()
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async stop() {
|
|
236
|
+
if (this.stopPromise) return this.stopPromise
|
|
237
|
+
this.stopPromise = this.#stopProcess()
|
|
238
|
+
try {
|
|
239
|
+
return await this.stopPromise
|
|
240
|
+
} finally {
|
|
241
|
+
this.stopPromise = null
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async #stopProcess() {
|
|
246
|
+
if (!this.process) {
|
|
247
|
+
this.setStatus('stopped')
|
|
248
|
+
return this.snapshot()
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
this.stopRequested = true
|
|
252
|
+
this.setStatus('stopping')
|
|
253
|
+
const child = this.process
|
|
254
|
+
await terminateProcess(child, 'SIGTERM')
|
|
255
|
+
|
|
256
|
+
const exited = await Promise.race([
|
|
257
|
+
new Promise((resolve) => child.once('exit', () => resolve(true))),
|
|
258
|
+
delay(STOP_TIMEOUT_MS).then(() => false),
|
|
259
|
+
])
|
|
260
|
+
|
|
261
|
+
if (!exited && this.process === child) {
|
|
262
|
+
await terminateProcess(child, 'SIGKILL')
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return this.snapshot()
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async restart(options = {}) {
|
|
269
|
+
await this.stop()
|
|
270
|
+
return this.start(options)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async runAction(_action, _options = {}) {
|
|
274
|
+
const error = new Error('Unsupported channel action')
|
|
275
|
+
error.statusCode = 404
|
|
276
|
+
throw error
|
|
277
|
+
}
|
|
278
|
+
}
|