@pugi/cli 0.1.0-beta.8 → 0.1.0-beta.9
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/THIRD_PARTY_NOTICES.md +40 -0
- package/dist/core/edits/worktree.js +322 -0
- package/dist/core/engine/prompts.js +8 -0
- package/dist/core/lsp/client.js +719 -0
- package/dist/core/repl/session.js +12 -0
- package/dist/core/repl/slash-commands.js +33 -0
- package/dist/core/skills/defaults.js +457 -0
- package/dist/runtime/cli.js +244 -32
- package/dist/runtime/commands/delegate.js +219 -11
- package/dist/runtime/commands/lsp.js +206 -0
- package/dist/runtime/commands/patch.js +128 -0
- package/dist/runtime/commands/worktree.js +177 -0
- package/dist/runtime/plan-decompose.js +531 -0
- package/dist/tools/apply-patch.js +495 -0
- package/dist/tools/lsp-tools.js +189 -0
- package/dist/tools/registry.js +26 -0
- package/dist/tui/repl-render.js +159 -32
- package/docs/examples/codegraph.mcp.json +10 -0
- package/package.json +18 -15
package/dist/tools/registry.js
CHANGED
|
@@ -1,8 +1,19 @@
|
|
|
1
1
|
const registry = [
|
|
2
|
+
// α7.7: unified-diff patch apply. Routes through the same security
|
|
3
|
+
// gate as Layer A/B/C, so the risk class matches `edit`/`write`
|
|
4
|
+
// (medium — writes inside the workspace, never to protected files).
|
|
5
|
+
{ name: 'apply_patch', permission: 'edit', risk: 'medium', concurrencySafe: false, m1: true },
|
|
2
6
|
{ name: 'bash', permission: 'bash', risk: 'high', concurrencySafe: false, m1: true },
|
|
3
7
|
{ name: 'edit', permission: 'edit', risk: 'medium', concurrencySafe: false, m1: true },
|
|
4
8
|
{ name: 'glob', permission: 'read', risk: 'low', concurrencySafe: true, m1: true },
|
|
5
9
|
{ name: 'grep', permission: 'read', risk: 'low', concurrencySafe: true, m1: true },
|
|
10
|
+
// α7.7: LSP read-only surface. Server runs locally, no Anvil
|
|
11
|
+
// round-trip. Concurrency-safe because every operation reads
|
|
12
|
+
// server state without mutating workspace files.
|
|
13
|
+
{ name: 'lsp_definition', permission: 'read', risk: 'low', concurrencySafe: true, m1: true },
|
|
14
|
+
{ name: 'lsp_diagnostics', permission: 'read', risk: 'low', concurrencySafe: true, m1: true },
|
|
15
|
+
{ name: 'lsp_hover', permission: 'read', risk: 'low', concurrencySafe: true, m1: true },
|
|
16
|
+
{ name: 'lsp_references', permission: 'read', risk: 'low', concurrencySafe: true, m1: true },
|
|
6
17
|
{ name: 'question', permission: 'none', risk: 'low', concurrencySafe: false, m1: true },
|
|
7
18
|
{ name: 'read', permission: 'read', risk: 'low', concurrencySafe: true, m1: true },
|
|
8
19
|
{ name: 'skill', permission: 'read', risk: 'low', concurrencySafe: true, m1: true },
|
|
@@ -11,6 +22,21 @@ const registry = [
|
|
|
11
22
|
{ name: 'task_list', permission: 'none', risk: 'low', concurrencySafe: true, m1: true },
|
|
12
23
|
{ name: 'task_update', permission: 'none', risk: 'low', concurrencySafe: false, m1: true },
|
|
13
24
|
{ name: 'web_fetch', permission: 'network', risk: 'medium', concurrencySafe: true, m1: true },
|
|
25
|
+
// α7.7: scratch worktree management. `worktree_create` writes nothing
|
|
26
|
+
// dangerous (a clone under `.pugi/worktrees/`); `worktree_promote`
|
|
27
|
+
// applies a diff back to the main tree, so it shares the `edit`
|
|
28
|
+
// risk class. `worktree_drop` is the cleanup primitive.
|
|
29
|
+
//
|
|
30
|
+
// R1 fix (2026-05-26, PR #413 r1, Fix 9): raised `worktree_create`
|
|
31
|
+
// and `worktree_drop` from `low` to `medium`. `worktree_drop` runs
|
|
32
|
+
// `rmSync` on its target — even with the new path-containment gate
|
|
33
|
+
// in `core/edits/worktree.ts::dropWorktree`, a destructive primitive
|
|
34
|
+
// belongs in `medium` so the permission FSM prompts on every call.
|
|
35
|
+
// `worktree_create` is raised for disk-pressure parity (a runaway
|
|
36
|
+
// agent loop could fill the disk with abandoned scratch worktrees).
|
|
37
|
+
{ name: 'worktree_create', permission: 'edit', risk: 'medium', concurrencySafe: false, m1: true },
|
|
38
|
+
{ name: 'worktree_drop', permission: 'edit', risk: 'medium', concurrencySafe: false, m1: true },
|
|
39
|
+
{ name: 'worktree_promote', permission: 'edit', risk: 'medium', concurrencySafe: false, m1: true },
|
|
14
40
|
{ name: 'write', permission: 'edit', risk: 'medium', concurrencySafe: false, m1: true },
|
|
15
41
|
];
|
|
16
42
|
export const toolRegistry = registry.sort((a, b) => a.name.localeCompare(b.name));
|
package/dist/tui/repl-render.js
CHANGED
|
@@ -31,6 +31,25 @@ import { WorkingSet, buildRepoSkeleton, loadPugiIgnore, PugiWatcher, } from '../
|
|
|
31
31
|
* `pugi resume <sessionId>` once that command exists).
|
|
32
32
|
*/
|
|
33
33
|
export async function renderRepl(options) {
|
|
34
|
+
// beta.9 CEO dogfood 2026-05-26: claim stdin raw mode + alt-screen
|
|
35
|
+
// BEFORE any async bootstrap step so keystrokes typed during the
|
|
36
|
+
// [launch -> Ink mount] window cannot echo into the terminal in
|
|
37
|
+
// cooked mode. Previously openLocalStore (SQLite open) +
|
|
38
|
+
// bootstrapContext (chokidar start) could take hundreds of ms to
|
|
39
|
+
// multiple seconds on a fresh install / large repo; during that
|
|
40
|
+
// window stdin stayed in cooked mode and the terminal echoed
|
|
41
|
+
// every typed character literally onto the screen below the
|
|
42
|
+
// pre-printed mascot/header. The visible result was the operator's
|
|
43
|
+
// "ssssss" landing on the rendered status-bar bottom row (CEO
|
|
44
|
+
// screenshot 2026-05-26: beta.8 REPL bug 2).
|
|
45
|
+
//
|
|
46
|
+
// The claim is idempotent with Ink's own raw-mode enable: Ink
|
|
47
|
+
// ref-counts setRawMode calls, and Node's stdin.setRawMode is
|
|
48
|
+
// safe to call twice with the same value. The pre-Ink claim acts
|
|
49
|
+
// as a "raw-mode floor" - whatever Ink does after mount layers on
|
|
50
|
+
// top, and our finally{} restore drops the floor only after Ink
|
|
51
|
+
// has cleanly torn down (or never mounted on a bootstrap crash).
|
|
52
|
+
const bootstrap = claimTerminalForRepl();
|
|
34
53
|
const transport = createProductionTransport();
|
|
35
54
|
// Auto-bind the workspace context from process.cwd() so Mira knows
|
|
36
55
|
// which repo the operator launched the CLI in. The resolver is
|
|
@@ -86,21 +105,14 @@ export async function renderRepl(options) {
|
|
|
86
105
|
// Kick off the connect; the Repl renders the connecting state until
|
|
87
106
|
// the session pushes `connection: 'on_watch'` from the SSE onOpen.
|
|
88
107
|
void session.start();
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
|
|
97
|
-
// BEFORE the chafa mascot pre-print. Reversed, the alt-screen clear
|
|
98
|
-
// wiped the freshly-painted pug and the operator saw nothing.
|
|
99
|
-
const supportsAltScreen = process.stdout.isTTY === true;
|
|
100
|
-
if (supportsAltScreen) {
|
|
101
|
-
process.stdout.write('\x1b[?1049h');
|
|
102
|
-
process.stdout.write('\x1b[H');
|
|
103
|
-
}
|
|
108
|
+
// beta.9: drain any keystrokes that landed in stdin between the
|
|
109
|
+
// pre-Ink raw-mode claim and now. Without this, the queued bytes
|
|
110
|
+
// would feed Ink's first useInput tick as a flood of "stale"
|
|
111
|
+
// characters once the InputBox mounts - the operator would see
|
|
112
|
+
// their pre-typed input materialise in the prompt as if they had
|
|
113
|
+
// typed it after the REPL became interactive. Idempotent: no-op
|
|
114
|
+
// when stdin is not a TTY or no bytes were buffered.
|
|
115
|
+
drainBufferedStdin(process.stdin);
|
|
104
116
|
// α6.14.2 wave 5: paint the chafa-baked brand-pug ANSI render to
|
|
105
117
|
// stdout BEFORE Ink mounts (but AFTER alt-screen enter). Ink's
|
|
106
118
|
// layout engine would mis-measure the truecolor escape sequences,
|
|
@@ -118,26 +130,16 @@ export async function renderRepl(options) {
|
|
|
118
130
|
hideToolStream: options.hideToolStream === true,
|
|
119
131
|
mascotPrePrinted,
|
|
120
132
|
}));
|
|
121
|
-
const restoreAltScreen = () => {
|
|
122
|
-
if (supportsAltScreen) {
|
|
123
|
-
try {
|
|
124
|
-
process.stdout.write('\x1b[?1049l');
|
|
125
|
-
}
|
|
126
|
-
catch {
|
|
127
|
-
/* shutdown race — terminal already detached */
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
};
|
|
131
133
|
// Make sure we leave the alt screen on abrupt exits too. Without
|
|
132
134
|
// this the operator's shell stays "frozen" on the Pugi splash.
|
|
133
|
-
process.once('exit',
|
|
134
|
-
process.once('SIGINT',
|
|
135
|
-
process.once('SIGTERM',
|
|
135
|
+
process.once('exit', bootstrap.restore);
|
|
136
|
+
process.once('SIGINT', bootstrap.restore);
|
|
137
|
+
process.once('SIGTERM', bootstrap.restore);
|
|
136
138
|
try {
|
|
137
139
|
await instance.waitUntilExit();
|
|
138
140
|
}
|
|
139
141
|
finally {
|
|
140
|
-
|
|
142
|
+
bootstrap.restore();
|
|
141
143
|
session.close();
|
|
142
144
|
if (store) {
|
|
143
145
|
try {
|
|
@@ -157,6 +159,89 @@ export async function renderRepl(options) {
|
|
|
157
159
|
}
|
|
158
160
|
}
|
|
159
161
|
}
|
|
162
|
+
export function claimTerminalForRepl(stdin = process.stdin, stdout = process.stdout) {
|
|
163
|
+
const isStdoutTty = stdout.isTTY === true;
|
|
164
|
+
const isStdinTty = stdin.isTTY === true && typeof stdin.setRawMode === 'function';
|
|
165
|
+
let altScreenEntered = false;
|
|
166
|
+
if (isStdoutTty) {
|
|
167
|
+
try {
|
|
168
|
+
stdout.write('\x1b[?1049h');
|
|
169
|
+
stdout.write('\x1b[H');
|
|
170
|
+
altScreenEntered = true;
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
/* terminal already detached */
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
let rawModeClaimed = false;
|
|
177
|
+
if (isStdinTty) {
|
|
178
|
+
try {
|
|
179
|
+
stdin.setEncoding('utf8');
|
|
180
|
+
stdin.setRawMode(true);
|
|
181
|
+
// Resume so the kernel actually delivers bytes to Node's event
|
|
182
|
+
// loop. Without resume, raw mode is set but data does not flow
|
|
183
|
+
// until something else (e.g. Ink) attaches a 'data' listener.
|
|
184
|
+
stdin.resume();
|
|
185
|
+
rawModeClaimed = true;
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
/* raw mode unsupported - the operator's shell still works */
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
let restored = false;
|
|
192
|
+
const restore = () => {
|
|
193
|
+
if (restored)
|
|
194
|
+
return;
|
|
195
|
+
restored = true;
|
|
196
|
+
if (rawModeClaimed && isStdinTty) {
|
|
197
|
+
try {
|
|
198
|
+
stdin.setRawMode(false);
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
/* terminal already detached */
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
if (altScreenEntered) {
|
|
205
|
+
try {
|
|
206
|
+
stdout.write('\x1b[?1049l');
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
/* shutdown race - terminal already detached */
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
return { altScreenEntered, rawModeClaimed, restore };
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Read and discard any bytes buffered in stdin between
|
|
217
|
+
* `claimTerminalForRepl()` and the Ink mount. Returns the number of
|
|
218
|
+
* bytes drained so tests can assert the behaviour without intercepting
|
|
219
|
+
* the side effect.
|
|
220
|
+
*
|
|
221
|
+
* `stdin.read()` is a no-op when no data is buffered, so this is safe
|
|
222
|
+
* to call whether or not the operator actually typed during bootstrap.
|
|
223
|
+
* Wrapped in try/catch because a closed / piped stdin will throw on
|
|
224
|
+
* read in some Node versions.
|
|
225
|
+
*/
|
|
226
|
+
export function drainBufferedStdin(stdin = process.stdin) {
|
|
227
|
+
if (stdin.isTTY !== true)
|
|
228
|
+
return 0;
|
|
229
|
+
try {
|
|
230
|
+
let bytesDrained = 0;
|
|
231
|
+
// Loop until read() returns null - readable streams may chunk
|
|
232
|
+
// buffered bytes across multiple read() calls when the operator
|
|
233
|
+
// typed faster than the kernel could deliver to Node's loop.
|
|
234
|
+
for (;;) {
|
|
235
|
+
const chunk = stdin.read();
|
|
236
|
+
if (chunk === null)
|
|
237
|
+
return bytesDrained;
|
|
238
|
+
bytesDrained += typeof chunk === 'string' ? chunk.length : chunk.byteLength;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
return 0;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
160
245
|
/**
|
|
161
246
|
* Open the local SessionStore for the REPL bootstrap. Returns
|
|
162
247
|
* `{ store: null, openedSessionId: undefined }` on any error so the
|
|
@@ -248,7 +333,7 @@ async function bootstrapContext(input) {
|
|
|
248
333
|
/* ------------------------------------------------------------------ */
|
|
249
334
|
/* Production transport */
|
|
250
335
|
/* ------------------------------------------------------------------ */
|
|
251
|
-
function createProductionTransport() {
|
|
336
|
+
export function createProductionTransport() {
|
|
252
337
|
return {
|
|
253
338
|
async createSession({ apiUrl, apiKey, workspace }) {
|
|
254
339
|
// Forward the workspace bundle in the POST body so admin-api can
|
|
@@ -307,6 +392,31 @@ function createProductionTransport() {
|
|
|
307
392
|
if (lastEventId) {
|
|
308
393
|
headers['Last-Event-ID'] = lastEventId;
|
|
309
394
|
}
|
|
395
|
+
// beta.9 CEO dogfood 2026-05-26: hard timeout on the SSE
|
|
396
|
+
// handshake so a CDN/proxy that buffers the response (or an
|
|
397
|
+
// admin-api that accepted the route but never flushed headers)
|
|
398
|
+
// cannot freeze the REPL in `connecting` forever. The 5s budget
|
|
399
|
+
// is generous - admin-api routinely responds in <500ms when
|
|
400
|
+
// healthy - but tight enough that an operator who launched
|
|
401
|
+
// `pugi` and is staring at the screen will see the status flip
|
|
402
|
+
// to `reconnecting` instead of an indefinite hang. The
|
|
403
|
+
// AbortController bound to the fetch aborts the in-flight
|
|
404
|
+
// request when the timer fires, which surfaces as an
|
|
405
|
+
// `AbortError` and routes through the existing onError handler
|
|
406
|
+
// (which calls scheduleReconnect via the session). The timer
|
|
407
|
+
// is cleared the moment onOpen fires so a slow-but-eventually-
|
|
408
|
+
// successful handshake still works.
|
|
409
|
+
const handshakeDeadlineMs = 5_000;
|
|
410
|
+
const handshakeTimer = setTimeout(() => {
|
|
411
|
+
controller.abort();
|
|
412
|
+
// onError is called from the catch block below (the abort
|
|
413
|
+
// synthesises an AbortError that consumeSseStream / fetch
|
|
414
|
+
// will throw). No explicit onError call here - we let the
|
|
415
|
+
// catch path normalise the error message so the operator
|
|
416
|
+
// sees the consistent "SSE handshake timed out (5s)" prose
|
|
417
|
+
// through the same plumbing that surfaces every other
|
|
418
|
+
// transport failure.
|
|
419
|
+
}, handshakeDeadlineMs);
|
|
310
420
|
void (async () => {
|
|
311
421
|
try {
|
|
312
422
|
const response = await fetch(url, {
|
|
@@ -320,6 +430,9 @@ function createProductionTransport() {
|
|
|
320
430
|
if (!response.body) {
|
|
321
431
|
throw new Error('SSE response has no body');
|
|
322
432
|
}
|
|
433
|
+
// Handshake survived; cancel the deadline so a slow
|
|
434
|
+
// first-event stream does not get aborted later.
|
|
435
|
+
clearTimeout(handshakeTimer);
|
|
323
436
|
onOpen();
|
|
324
437
|
await consumeSseStream(response.body, onEvent);
|
|
325
438
|
// Server closed the stream cleanly. Treat as an error so
|
|
@@ -329,13 +442,27 @@ function createProductionTransport() {
|
|
|
329
442
|
onError(new Error('SSE stream ended'));
|
|
330
443
|
}
|
|
331
444
|
catch (error) {
|
|
332
|
-
|
|
445
|
+
clearTimeout(handshakeTimer);
|
|
446
|
+
if (controller.signal.aborted) {
|
|
447
|
+
// Distinguish operator-driven close (session.close())
|
|
448
|
+
// from the handshake-deadline abort. The session sets a
|
|
449
|
+
// `closed` flag before calling controller.abort(); the
|
|
450
|
+
// handshake-deadline abort fires while the session is
|
|
451
|
+
// still expecting onOpen. We cannot read session state
|
|
452
|
+
// from here, so we surface a single error class with a
|
|
453
|
+
// clear message - the session-side onError handler
|
|
454
|
+
// already short-circuits when `closed=true`.
|
|
455
|
+
onError(new Error(`SSE handshake timed out after ${handshakeDeadlineMs}ms`));
|
|
333
456
|
return;
|
|
457
|
+
}
|
|
334
458
|
onError(error instanceof Error ? error : new Error(String(error)));
|
|
335
459
|
}
|
|
336
460
|
})();
|
|
337
461
|
return {
|
|
338
|
-
close: () =>
|
|
462
|
+
close: () => {
|
|
463
|
+
clearTimeout(handshakeTimer);
|
|
464
|
+
controller.abort();
|
|
465
|
+
},
|
|
339
466
|
};
|
|
340
467
|
},
|
|
341
468
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pugi/cli",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.9",
|
|
4
4
|
"description": "Pugi CLI - terminal-native software execution system",
|
|
5
5
|
"homepage": "https://pugi.io",
|
|
6
6
|
"repository": {
|
|
@@ -29,8 +29,10 @@
|
|
|
29
29
|
"bin/run.js",
|
|
30
30
|
"dist/**/*.js",
|
|
31
31
|
"assets/**/*.ansi",
|
|
32
|
+
"docs/examples/**/*.json",
|
|
32
33
|
"README.md",
|
|
33
|
-
"LICENSE"
|
|
34
|
+
"LICENSE",
|
|
35
|
+
"THIRD_PARTY_NOTICES.md"
|
|
34
36
|
],
|
|
35
37
|
"engines": {
|
|
36
38
|
"node": ">=22.5.0"
|
|
@@ -39,8 +41,20 @@
|
|
|
39
41
|
"publishConfig": {
|
|
40
42
|
"access": "public"
|
|
41
43
|
},
|
|
44
|
+
"scripts": {
|
|
45
|
+
"build": "pnpm --filter @pugi/personas --filter @pugi/sdk build && tsc -p tsconfig.json && node scripts/make-bin-executable.mjs",
|
|
46
|
+
"dev": "tsx src/index.ts",
|
|
47
|
+
"typecheck": "pnpm --filter @pugi/personas --filter @pugi/sdk build && tsc -p tsconfig.json --noEmit",
|
|
48
|
+
"test": "pnpm run build && node --test --import tsx 'test/**/*.spec.ts' 'test/**/*.spec.tsx'",
|
|
49
|
+
"version:cli": "tsx src/index.ts version",
|
|
50
|
+
"doctor": "tsx src/index.ts doctor --json",
|
|
51
|
+
"prepublishOnly": "pnpm --filter @pugi/personas --filter @pugi/sdk build && pnpm run build && node scripts/pack-smoke.mjs",
|
|
52
|
+
"pack:smoke": "node scripts/pack-smoke.mjs"
|
|
53
|
+
},
|
|
42
54
|
"dependencies": {
|
|
43
55
|
"@mozilla/readability": "^0.6.0",
|
|
56
|
+
"@pugi/personas": "workspace:*",
|
|
57
|
+
"@pugi/sdk": "workspace:*",
|
|
44
58
|
"chokidar": "^3.6.0",
|
|
45
59
|
"ignore": "^5.3.2",
|
|
46
60
|
"ink": "^5.0.1",
|
|
@@ -50,9 +64,7 @@
|
|
|
50
64
|
"tinyglobby": "^0.2.16",
|
|
51
65
|
"turndown": "^7.2.4",
|
|
52
66
|
"undici": "^8.3.0",
|
|
53
|
-
"zod": "^3.23.0"
|
|
54
|
-
"@pugi/personas": "0.1.2",
|
|
55
|
-
"@pugi/sdk": "0.1.0-beta.8"
|
|
67
|
+
"zod": "^3.23.0"
|
|
56
68
|
},
|
|
57
69
|
"devDependencies": {
|
|
58
70
|
"@types/node": "^22.0.0",
|
|
@@ -62,14 +74,5 @@
|
|
|
62
74
|
"ink-testing-library": "^4.0.0",
|
|
63
75
|
"tsx": "^4.19.0",
|
|
64
76
|
"typescript": "~5.6.0"
|
|
65
|
-
},
|
|
66
|
-
"scripts": {
|
|
67
|
-
"build": "pnpm --filter @pugi/personas --filter @pugi/sdk build && tsc -p tsconfig.json && node scripts/make-bin-executable.mjs",
|
|
68
|
-
"dev": "tsx src/index.ts",
|
|
69
|
-
"typecheck": "pnpm --filter @pugi/personas --filter @pugi/sdk build && tsc -p tsconfig.json --noEmit",
|
|
70
|
-
"test": "pnpm run build && node --test --import tsx 'test/**/*.spec.ts' 'test/**/*.spec.tsx'",
|
|
71
|
-
"version:cli": "tsx src/index.ts version",
|
|
72
|
-
"doctor": "tsx src/index.ts doctor --json",
|
|
73
|
-
"pack:smoke": "node scripts/pack-smoke.mjs"
|
|
74
77
|
}
|
|
75
|
-
}
|
|
78
|
+
}
|