acrylblends 0.9.2 → 0.10.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/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/plugins/agent-panel/client/main.ts +70 -2
- package/plugins/agent-panel/drivers.ts +83 -0
- package/plugins/agent-panel/headless.ts +154 -0
- package/plugins/agent-panel/index.ts +42 -125
- package/plugins/agent-panel/plugin.json +1 -1
- package/plugins/driver-pty/client/terminal.ts +188 -0
- package/plugins/driver-pty/client/vendor/LICENSE +21 -0
- package/plugins/driver-pty/client/vendor/LICENSE-addon-fit +19 -0
- package/plugins/driver-pty/client/vendor/VERSION +4 -0
- package/plugins/driver-pty/client/vendor/addon-fit.d.mts +6 -0
- package/plugins/driver-pty/client/vendor/addon-fit.mjs +18 -0
- package/plugins/driver-pty/client/vendor/xterm-css.d.mts +2 -0
- package/plugins/driver-pty/client/vendor/xterm-css.mjs +2 -0
- package/plugins/driver-pty/client/vendor/xterm.d.mts +17 -0
- package/plugins/driver-pty/client/vendor/xterm.mjs +53 -0
- package/plugins/driver-pty/index.ts +249 -0
- package/plugins/driver-pty/plugin.json +13 -0
- package/plugins/driver-pty/spawn.ts +36 -0
- package/plugins/driver-pty/terminal.ts +84 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The PTY driver (M8, spec 046 T029): `claude`, `codex`, `pi` or any program in a real terminal inside the app folder, shown as a terminal tab of the agent panel. The agent
|
|
3
|
+
* acts through the door as it does headless; the person sees and types into its own interactive mode.
|
|
4
|
+
*
|
|
5
|
+
* - The drivers are `terminal` entries of the panel's `drivers` service (`pty:claude`, ...). A panel without this plugin lists none; this plugin without a panel is pending.
|
|
6
|
+
* - Transport is server-sent events down and POST up (no WebSocket): `POST /api/driver-pty/sessions`, `GET /api/driver-pty/<id>/output` (events with ids; a reconnect replays
|
|
7
|
+
* from `Last-Event-ID`, from a ring buffer of at least 64 KB), `POST .../input`, `.../resize`, `.../stop`. Same origin check and token as the panel, development only.
|
|
8
|
+
* - Every process is acquired inside one `ctx.effect`; its disposer ends the process (group), closes the streams and writes the audit line. Unloading or reloading the
|
|
9
|
+
* plugin with a session open leaves no process behind.
|
|
10
|
+
*/
|
|
11
|
+
import { appendFileSync, mkdirSync } from 'node:fs'
|
|
12
|
+
import { dirname, join } from 'node:path'
|
|
13
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
14
|
+
import { clientModules } from '../acryl-node-server/src/client-modules.ts'
|
|
15
|
+
import { streamSSE } from '../acryl-router/src/helpers/index.ts'
|
|
16
|
+
import { Acryl } from '../acryl-router/src/index.ts'
|
|
17
|
+
import type { TerminalDriver, TerminalSession } from '../agent-panel/drivers.ts'
|
|
18
|
+
import { onPath } from '../agent-panel/headless.ts'
|
|
19
|
+
import { loadNodePty, type PtySpawn } from './spawn.ts'
|
|
20
|
+
import { terminalDriver } from './terminal.ts'
|
|
21
|
+
|
|
22
|
+
export const name = 'driver-pty'
|
|
23
|
+
export const inject = ['drivers', 'router', 'appInstance']
|
|
24
|
+
|
|
25
|
+
export type { PtyProcess, PtySpawn } from './spawn.ts'
|
|
26
|
+
export { terminalDriver } from './terminal.ts'
|
|
27
|
+
|
|
28
|
+
const PREFIX = '/api/driver-pty'
|
|
29
|
+
const CLIENT_DIR = join(import.meta.dirname, 'client')
|
|
30
|
+
/** The least output a reconnecting page can be given again. */
|
|
31
|
+
const SCROLLBACK = 64 * 1024
|
|
32
|
+
|
|
33
|
+
export interface DriverPtyConfig {
|
|
34
|
+
/** Programs to offer, by name: the arguments to start them with. Default: `claude`, `codex` and `pi`, the ones found on the machine. */
|
|
35
|
+
programs?: Record<string, string[]>
|
|
36
|
+
/** The PTY implementation; default node-pty. Tests give a fake. */
|
|
37
|
+
spawn?: PtySpawn
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface Chunk {
|
|
41
|
+
readonly id: number
|
|
42
|
+
readonly text: string
|
|
43
|
+
}
|
|
44
|
+
type Item = Chunk | { exit: number | null } | { change: { extension: string; action: string } }
|
|
45
|
+
interface Live {
|
|
46
|
+
readonly id: string
|
|
47
|
+
readonly driver: string
|
|
48
|
+
readonly session: TerminalSession
|
|
49
|
+
readonly chunks: Chunk[]
|
|
50
|
+
size: number
|
|
51
|
+
next: number
|
|
52
|
+
exit?: { code: number | null }
|
|
53
|
+
readonly listeners: Set<
|
|
54
|
+
(
|
|
55
|
+
item: Chunk | { exit: number | null } | { change: { extension: string; action: string } },
|
|
56
|
+
) => Promise<void> | void
|
|
57
|
+
>
|
|
58
|
+
dispose(): Promise<void>
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function apply(ctx: Context, config: DriverPtyConfig = {}): Promise<void> {
|
|
62
|
+
if (process.env.NODE_ENV === 'production' || process.env.ACRYL_DOOR === 'off') return
|
|
63
|
+
const root = ctx.appInstance.root
|
|
64
|
+
let spawn = config.spawn
|
|
65
|
+
if (!spawn) {
|
|
66
|
+
try {
|
|
67
|
+
spawn = await loadNodePty()
|
|
68
|
+
} catch (error) {
|
|
69
|
+
// No PTY here (no prebuilt binary, a sandbox): the panel lists no `pty:*` drivers, and says why.
|
|
70
|
+
console.warn(
|
|
71
|
+
`driver-pty: no terminal drivers, ${error instanceof Error ? error.message : String(error)}`,
|
|
72
|
+
)
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const programs =
|
|
77
|
+
config.programs ??
|
|
78
|
+
Object.fromEntries(
|
|
79
|
+
(['claude', 'codex', 'pi'] as const).filter(onPath).map((p) => [p, [] as string[]]),
|
|
80
|
+
)
|
|
81
|
+
for (const [program, args] of Object.entries(programs))
|
|
82
|
+
ctx.effect(() => ctx.drivers.register(terminalDriver(`pty:${program}`, program, args, spawn)))
|
|
83
|
+
|
|
84
|
+
const auditFile = join(root, '.acryl', 'audit', 'driver-pty.jsonl')
|
|
85
|
+
const audit = (entry: Record<string, unknown>): void => {
|
|
86
|
+
try {
|
|
87
|
+
mkdirSync(dirname(auditFile), { recursive: true })
|
|
88
|
+
appendFileSync(auditFile, `${JSON.stringify({ at: new Date().toISOString(), ...entry })}\n`)
|
|
89
|
+
} catch {
|
|
90
|
+
// The audit never stops a session.
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
let live: Live | undefined
|
|
95
|
+
const refuse = (c: { json: (body: unknown, status: 403) => Response }) =>
|
|
96
|
+
c.json({ error: "only the app's own page may use the terminal" }, 403)
|
|
97
|
+
const find = (id: string): Live | undefined => (live?.id === id ? live : undefined)
|
|
98
|
+
|
|
99
|
+
/** Starts a session; the process is acquired in one effect whose disposer ends it, however the session ends. */
|
|
100
|
+
const startSession = (driver: TerminalDriver, cols: number, rows: number): Live => {
|
|
101
|
+
let state!: Live
|
|
102
|
+
let release!: () => Promise<void>
|
|
103
|
+
let notified = false
|
|
104
|
+
let finished = false
|
|
105
|
+
/** Tells every open stream the session ended (once), and ends it for later readers. */
|
|
106
|
+
const notify = async (code: number | null): Promise<void> => {
|
|
107
|
+
if (notified) return
|
|
108
|
+
notified = true
|
|
109
|
+
state.exit = { code }
|
|
110
|
+
const writes = [...state.listeners].map((listener) => listener({ exit: code }))
|
|
111
|
+
state.listeners.clear()
|
|
112
|
+
// Each write is accepted once its reader has taken it; a reader that went away must not hold the unload.
|
|
113
|
+
await Promise.race([
|
|
114
|
+
Promise.allSettled(writes),
|
|
115
|
+
new Promise((done) => setTimeout(done, 1000)),
|
|
116
|
+
])
|
|
117
|
+
}
|
|
118
|
+
const finish = (code: number | null): void => {
|
|
119
|
+
void notify(code)
|
|
120
|
+
if (finished) return
|
|
121
|
+
finished = true
|
|
122
|
+
audit({ session: 'end', driver: driver.name, code })
|
|
123
|
+
}
|
|
124
|
+
release = ctx.effect(() => {
|
|
125
|
+
const session = driver.start({ cwd: root, env: process.env, cols, rows })
|
|
126
|
+
state = {
|
|
127
|
+
id: session.id,
|
|
128
|
+
driver: driver.name,
|
|
129
|
+
session,
|
|
130
|
+
chunks: [],
|
|
131
|
+
size: 0,
|
|
132
|
+
next: 1,
|
|
133
|
+
listeners: new Set(),
|
|
134
|
+
dispose: () => release(),
|
|
135
|
+
}
|
|
136
|
+
session.onData((text) => {
|
|
137
|
+
const chunk = { id: state.next++, text }
|
|
138
|
+
state.chunks.push(chunk)
|
|
139
|
+
state.size += text.length
|
|
140
|
+
while (state.chunks.length > 1 && state.size - state.chunks[0]!.text.length >= SCROLLBACK)
|
|
141
|
+
state.size -= state.chunks.shift()!.text.length
|
|
142
|
+
for (const listener of state.listeners) listener(chunk)
|
|
143
|
+
})
|
|
144
|
+
session.onExit((code) => finish(code))
|
|
145
|
+
return async () => {
|
|
146
|
+
// The streams are told first: unloading the plugin also takes its routes (and their open requests) down, and the page should hear why.
|
|
147
|
+
await notify(null)
|
|
148
|
+
await session.kill()
|
|
149
|
+
finish(null)
|
|
150
|
+
}
|
|
151
|
+
}) as () => Promise<void>
|
|
152
|
+
audit({ session: 'start', driver: driver.name })
|
|
153
|
+
return state
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// A plugin installed or removed while a terminal is open (by the agent, through the door): the page is told, so it reloads and shows the new feature.
|
|
157
|
+
const off = ctx.on('extensions/changed', (extension: string, action: string) => {
|
|
158
|
+
if (live && !live.exit)
|
|
159
|
+
for (const listener of live.listeners) void listener({ change: { extension, action } })
|
|
160
|
+
})
|
|
161
|
+
ctx.effect(() => off)
|
|
162
|
+
|
|
163
|
+
const app = new Acryl()
|
|
164
|
+
.route(`${PREFIX}/client`, clientModules({ root: CLIENT_DIR }))
|
|
165
|
+
.post(`${PREFIX}/sessions`, async (c) => {
|
|
166
|
+
if (!ctx.drivers.authorized(c.req.raw.headers)) return refuse(c)
|
|
167
|
+
const body = (await c.req.json().catch(() => ({}))) as {
|
|
168
|
+
driver?: unknown
|
|
169
|
+
cols?: unknown
|
|
170
|
+
rows?: unknown
|
|
171
|
+
}
|
|
172
|
+
const driver = typeof body.driver === 'string' ? ctx.drivers.get(body.driver) : undefined
|
|
173
|
+
if (driver?.kind !== 'terminal')
|
|
174
|
+
return c.json({ error: `"${String(body.driver)}" is not a terminal driver here` }, 400)
|
|
175
|
+
if (live && !live.exit)
|
|
176
|
+
return c.json({ error: 'a terminal is already open; stop it first' }, 409)
|
|
177
|
+
const num = (value: unknown, fallback: number) =>
|
|
178
|
+
typeof value === 'number' && value >= 1 && value <= 1000 ? Math.floor(value) : fallback
|
|
179
|
+
live = startSession(driver, num(body.cols, 80), num(body.rows, 24))
|
|
180
|
+
return c.json({ id: live.id })
|
|
181
|
+
})
|
|
182
|
+
.get(`${PREFIX}/:id/output`, (c) => {
|
|
183
|
+
if (!ctx.drivers.authorized(c.req.raw.headers)) return refuse(c)
|
|
184
|
+
const current = find(c.req.param('id'))
|
|
185
|
+
if (!current) return c.json({ error: 'no such terminal' }, 404)
|
|
186
|
+
const last = Number(c.req.header('last-event-id') ?? 0)
|
|
187
|
+
return streamSSE(c, async (stream) => {
|
|
188
|
+
const send = (chunk: Chunk): Promise<void> =>
|
|
189
|
+
stream.writeSSE({ event: 'data', id: String(chunk.id), data: JSON.stringify(chunk.text) })
|
|
190
|
+
// A reconnect that is older than the buffer starts over: the page clears its terminal first.
|
|
191
|
+
const oldest = current.chunks[0]?.id ?? current.next
|
|
192
|
+
if (last !== 0 && last + 1 < oldest) await stream.writeSSE({ event: 'reset', data: '{}' })
|
|
193
|
+
for (const chunk of current.chunks) if (chunk.id > last) await send(chunk)
|
|
194
|
+
if (current.exit) {
|
|
195
|
+
await stream.writeSSE({
|
|
196
|
+
event: 'exit',
|
|
197
|
+
data: JSON.stringify({ code: current.exit.code }),
|
|
198
|
+
})
|
|
199
|
+
return
|
|
200
|
+
}
|
|
201
|
+
await new Promise<void>((done) => {
|
|
202
|
+
const listener = async (item: Item): Promise<void> => {
|
|
203
|
+
if ('change' in item)
|
|
204
|
+
return stream.writeSSE({ event: 'change', data: JSON.stringify(item.change) })
|
|
205
|
+
if (!('exit' in item)) return send(item)
|
|
206
|
+
await stream
|
|
207
|
+
.writeSSE({ event: 'exit', data: JSON.stringify({ code: item.exit }) })
|
|
208
|
+
.finally(done)
|
|
209
|
+
}
|
|
210
|
+
current.listeners.add(listener)
|
|
211
|
+
stream.onAbort(() => {
|
|
212
|
+
current.listeners.delete(listener)
|
|
213
|
+
done()
|
|
214
|
+
})
|
|
215
|
+
})
|
|
216
|
+
})
|
|
217
|
+
})
|
|
218
|
+
.post(`${PREFIX}/:id/input`, async (c) => {
|
|
219
|
+
if (!ctx.drivers.authorized(c.req.raw.headers)) return refuse(c)
|
|
220
|
+
const current = find(c.req.param('id'))
|
|
221
|
+
if (!current || current.exit) return c.json({ error: 'no such terminal' }, 404)
|
|
222
|
+
const { data } = (await c.req.json().catch(() => ({}))) as { data?: unknown }
|
|
223
|
+
if (typeof data !== 'string' || data.length > 64 * 1024)
|
|
224
|
+
return c.json({ error: 'input is a string of at most 64 KB' }, 400)
|
|
225
|
+
current.session.write(data)
|
|
226
|
+
return c.json({ ok: true })
|
|
227
|
+
})
|
|
228
|
+
.post(`${PREFIX}/:id/resize`, async (c) => {
|
|
229
|
+
if (!ctx.drivers.authorized(c.req.raw.headers)) return refuse(c)
|
|
230
|
+
const current = find(c.req.param('id'))
|
|
231
|
+
if (!current || current.exit) return c.json({ error: 'no such terminal' }, 404)
|
|
232
|
+
const { cols, rows } = (await c.req.json().catch(() => ({}))) as {
|
|
233
|
+
cols?: number
|
|
234
|
+
rows?: number
|
|
235
|
+
}
|
|
236
|
+
if (typeof cols !== 'number' || typeof rows !== 'number')
|
|
237
|
+
return c.json({ error: 'cols and rows are numbers' }, 400)
|
|
238
|
+
current.session.resize(cols, rows)
|
|
239
|
+
return c.json({ ok: true })
|
|
240
|
+
})
|
|
241
|
+
.post(`${PREFIX}/:id/stop`, async (c) => {
|
|
242
|
+
if (!ctx.drivers.authorized(c.req.raw.headers)) return refuse(c)
|
|
243
|
+
const current = find(c.req.param('id'))
|
|
244
|
+
if (!current) return c.json({ error: 'no such terminal' }, 404)
|
|
245
|
+
await current.dispose()
|
|
246
|
+
return c.json({ ok: true })
|
|
247
|
+
})
|
|
248
|
+
ctx.router.route('/', app)
|
|
249
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "driver-pty",
|
|
3
|
+
"description": "A coding agent (claude, codex, pi, any program) in a real terminal inside the running app: the panel gets a terminal tab, the agent builds through the door in its own interactive mode. Development only; needs the agent panel; opt-in with acrylblends add driver-pty.",
|
|
4
|
+
"requires": ["acryl-router", "acryl-node-server", "agent-panel", "instance"],
|
|
5
|
+
"inject": ["drivers", "router", "appInstance"],
|
|
6
|
+
"provides": [],
|
|
7
|
+
"dependencies": {
|
|
8
|
+
"@deepseek-ai/cordis": "4.0.4",
|
|
9
|
+
"node-pty": "1.2.0-beta.15"
|
|
10
|
+
},
|
|
11
|
+
"mount": "register it in src/app.ts ('driver-pty': driverPty) and add a row `- id: driver-pty` / `name: driver-pty` to blend.yaml AFTER the agent-panel row; open the panel and pick a `pty:` agent to get its terminal",
|
|
12
|
+
"gitignore": [".acryl/"]
|
|
13
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** The one place that touches `node-pty`, a native module. Everything else works on this small seam, so tests run on a fake process. */
|
|
2
|
+
|
|
3
|
+
export interface PtyProcess {
|
|
4
|
+
readonly pid: number
|
|
5
|
+
onData(listener: (chunk: string) => void): void
|
|
6
|
+
onExit(listener: (exit: { exitCode: number }) => void): void
|
|
7
|
+
write(data: string): void
|
|
8
|
+
resize(cols: number, rows: number): void
|
|
9
|
+
/** Ends the process; on Windows this is how it ends (ConPTY has no process groups). */
|
|
10
|
+
kill(): void
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type PtySpawn = (
|
|
14
|
+
file: string,
|
|
15
|
+
args: string[],
|
|
16
|
+
options: { cwd: string; env: NodeJS.ProcessEnv; cols: number; rows: number },
|
|
17
|
+
) => PtyProcess
|
|
18
|
+
|
|
19
|
+
/** Loads node-pty. Rejects with the reason (the error and the platform), so a log says why the driver is not offered. */
|
|
20
|
+
export async function loadNodePty(): Promise<PtySpawn> {
|
|
21
|
+
try {
|
|
22
|
+
const pty = await import('node-pty')
|
|
23
|
+
return (file, args, options) =>
|
|
24
|
+
pty.spawn(file, args, {
|
|
25
|
+
name: 'xterm-256color',
|
|
26
|
+
cwd: options.cwd,
|
|
27
|
+
env: options.env as Record<string, string>,
|
|
28
|
+
cols: options.cols,
|
|
29
|
+
rows: options.rows,
|
|
30
|
+
})
|
|
31
|
+
} catch (error) {
|
|
32
|
+
throw new Error(
|
|
33
|
+
`node-pty could not be loaded on ${process.platform}/${process.arch}: ${error instanceof Error ? error.message : String(error)}`,
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/** A terminal driver: a program started in a pseudo-terminal, ended as a whole (the process group on POSIX, the console on Windows). */
|
|
2
|
+
import { delimiter, join } from 'node:path'
|
|
3
|
+
import type { TerminalDriver, TerminalSession } from '../agent-panel/drivers.ts'
|
|
4
|
+
import type { PtySpawn } from './spawn.ts'
|
|
5
|
+
|
|
6
|
+
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
7
|
+
|
|
8
|
+
/** Whether any process of the group `pid` leads is still alive. */
|
|
9
|
+
function groupAlive(pid: number): boolean {
|
|
10
|
+
try {
|
|
11
|
+
process.kill(process.platform === 'win32' ? pid : -pid, 0)
|
|
12
|
+
return true
|
|
13
|
+
} catch (error) {
|
|
14
|
+
return (error as NodeJS.ErrnoException).code === 'EPERM'
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let counter = 0
|
|
19
|
+
|
|
20
|
+
export function terminalDriver(
|
|
21
|
+
name: string,
|
|
22
|
+
program: string,
|
|
23
|
+
args: string[],
|
|
24
|
+
spawn: PtySpawn,
|
|
25
|
+
): TerminalDriver {
|
|
26
|
+
return {
|
|
27
|
+
kind: 'terminal',
|
|
28
|
+
name,
|
|
29
|
+
start({ cwd, env, cols, rows }): TerminalSession {
|
|
30
|
+
// The agent runs `acryl look` without the folder prefix.
|
|
31
|
+
const path = [join(cwd, '.acryl', 'bin'), env.PATH].filter(Boolean).join(delimiter)
|
|
32
|
+
const proc = spawn(program, args, { cwd, env: { ...env, PATH: path }, cols, rows })
|
|
33
|
+
const dataListeners = new Set<(chunk: string) => void>()
|
|
34
|
+
const exitListeners = new Set<(code: number | null) => void>()
|
|
35
|
+
let exited = false
|
|
36
|
+
proc.onData((chunk) => {
|
|
37
|
+
for (const listener of dataListeners) listener(chunk)
|
|
38
|
+
})
|
|
39
|
+
proc.onExit(({ exitCode }) => {
|
|
40
|
+
exited = true
|
|
41
|
+
for (const listener of exitListeners) listener(exitCode)
|
|
42
|
+
})
|
|
43
|
+
const signalGroup = (signal: NodeJS.Signals): void => {
|
|
44
|
+
try {
|
|
45
|
+
process.kill(-proc.pid, signal)
|
|
46
|
+
} catch {
|
|
47
|
+
// The group is already gone.
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const gone = async (ms: number): Promise<boolean> => {
|
|
51
|
+
for (let waited = 0; waited < ms; waited += 25) {
|
|
52
|
+
if (process.platform === 'win32' ? exited : !groupAlive(proc.pid)) return true
|
|
53
|
+
await sleep(25)
|
|
54
|
+
}
|
|
55
|
+
return process.platform === 'win32' ? exited : !groupAlive(proc.pid)
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
id: `${name}-${++counter}`,
|
|
59
|
+
onData(listener) {
|
|
60
|
+
dataListeners.add(listener)
|
|
61
|
+
return () => void dataListeners.delete(listener)
|
|
62
|
+
},
|
|
63
|
+
onExit(listener) {
|
|
64
|
+
exitListeners.add(listener)
|
|
65
|
+
return () => void exitListeners.delete(listener)
|
|
66
|
+
},
|
|
67
|
+
write: (data) => proc.write(data),
|
|
68
|
+
resize: (c, r) => proc.resize(Math.max(1, c), Math.max(1, r)),
|
|
69
|
+
async kill() {
|
|
70
|
+
if (await gone(0)) return
|
|
71
|
+
if (process.platform === 'win32') {
|
|
72
|
+
proc.kill()
|
|
73
|
+
await gone(2000)
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
signalGroup('SIGHUP')
|
|
77
|
+
if (await gone(2000)) return
|
|
78
|
+
signalGroup('SIGKILL')
|
|
79
|
+
await gone(2000)
|
|
80
|
+
},
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
}
|
|
84
|
+
}
|