storymode-cli 1.1.2 → 1.2.1
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 +34 -38
- package/package.json +1 -1
- package/src/agent.mjs +282 -0
- package/src/browse.mjs +91 -7
- package/src/cache.mjs +189 -0
- package/src/cli.mjs +179 -54
- package/src/config.mjs +104 -0
- package/src/hook.mjs +70 -0
- package/src/hooks.mjs +150 -0
- package/src/mcp.mjs +10 -7
- package/src/player.mjs +436 -0
- package/src/reactive.mjs +442 -0
package/src/player.mjs
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
import { createServer } from 'node:net';
|
|
2
|
+
import { unlinkSync, existsSync } from 'node:fs';
|
|
3
|
+
import { mapEventToAnimation, pickSpeech, ONE_SHOT_ANIMS, IDLE_ANIM, SLEEP_ANIM, SLEEP_TIMEOUT_S, narrateEvent, SessionContext, StateEngine } from './reactive.mjs';
|
|
4
|
+
import { createAgent } from './agent.mjs';
|
|
5
|
+
import { getApiKey, hasSeenKeyPrompt, promptForApiKey } from './config.mjs';
|
|
6
|
+
|
|
1
7
|
const { stdout, stdin } = process;
|
|
2
8
|
|
|
3
9
|
export function playAnimation(framesData) {
|
|
@@ -201,6 +207,436 @@ export function playCompanion(framesData) {
|
|
|
201
207
|
});
|
|
202
208
|
}
|
|
203
209
|
|
|
210
|
+
/**
|
|
211
|
+
* Reactive companion player — responds to Claude Code events via Unix socket.
|
|
212
|
+
*
|
|
213
|
+
* @param {Map<string, {fps: number, frames: string[][]}>} animMap - All animations keyed by name
|
|
214
|
+
* @param {object} [opts]
|
|
215
|
+
* @param {string} [opts.characterName] - Character name for status display
|
|
216
|
+
* @param {object} [opts.character] - Character data {name, backstory, ...} for LLM personality
|
|
217
|
+
* @param {object} [opts.personality] - Full personality.json data { character, disposition, animations, speech }
|
|
218
|
+
* @param {boolean} [opts.fullscreen] - Use alt screen (full-screen mode)
|
|
219
|
+
* @param {boolean} [opts.noAi] - Disable LLM personality layer
|
|
220
|
+
* @param {string} [opts.model] - Anthropic model ID for personality layer
|
|
221
|
+
*/
|
|
222
|
+
export async function playReactiveCompanion(animMap, opts = {}) {
|
|
223
|
+
const characterName = opts.characterName || 'companion';
|
|
224
|
+
const personality = opts.personality || null;
|
|
225
|
+
let character = opts.noAi ? null : (personality || opts.character || null);
|
|
226
|
+
const fullscreen = !!opts.fullscreen;
|
|
227
|
+
|
|
228
|
+
// --- API key prompt (before terminal setup) ---
|
|
229
|
+
if (character && !opts.noAi && !getApiKey()) {
|
|
230
|
+
if (!hasSeenKeyPrompt()) {
|
|
231
|
+
// First time: full interactive prompt
|
|
232
|
+
await promptForApiKey('first-run');
|
|
233
|
+
} else {
|
|
234
|
+
// Subsequent launches: short reminder
|
|
235
|
+
process.stderr.write(' AI personality: OFF — press [a] to add your API key\n');
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Find idle animation (required)
|
|
240
|
+
const idleData = animMap.get(IDLE_ANIM);
|
|
241
|
+
if (!idleData || idleData.frames.length === 0) {
|
|
242
|
+
console.log('No idle animation found. Need "idle breathing" animation.');
|
|
243
|
+
return Promise.resolve();
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Calculate maxLines across ALL animations for stable layout
|
|
247
|
+
let maxLines = 0;
|
|
248
|
+
for (const [, data] of animMap) {
|
|
249
|
+
for (const frame of data.frames) {
|
|
250
|
+
if (frame.length > maxLines) maxLines = frame.length;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Speech bubble: max 3 lines + border (top + bottom) + blank line = 6 extra lines
|
|
255
|
+
const SPEECH_CLEAR_MS = 6000;
|
|
256
|
+
const BUBBLE_MAX_LINES = 3;
|
|
257
|
+
const bubbleLines = BUBBLE_MAX_LINES + 3; // top border + text lines + bottom border
|
|
258
|
+
|
|
259
|
+
// Status line + quit hint + speech bubble area
|
|
260
|
+
const totalLines = maxLines + 1 + bubbleLines;
|
|
261
|
+
|
|
262
|
+
// Animation state
|
|
263
|
+
let currentAnimName = IDLE_ANIM;
|
|
264
|
+
let currentFrames = idleData.frames;
|
|
265
|
+
let currentFps = idleData.fps || 16;
|
|
266
|
+
let frameIdx = 0;
|
|
267
|
+
let loopCount = 0;
|
|
268
|
+
let oneShotLoopsRemaining = 0; // >0 means playing a one-shot
|
|
269
|
+
let quit = false;
|
|
270
|
+
|
|
271
|
+
// Sleep state
|
|
272
|
+
let lastEventTime = Date.now();
|
|
273
|
+
let sleeping = false;
|
|
274
|
+
|
|
275
|
+
// Speech bubble state
|
|
276
|
+
let speechText = null;
|
|
277
|
+
let speechSetAt = 0;
|
|
278
|
+
let lastSpeech = null;
|
|
279
|
+
|
|
280
|
+
// Recent animation tracking (avoid repeats)
|
|
281
|
+
const recentAnims = [];
|
|
282
|
+
|
|
283
|
+
// Session context
|
|
284
|
+
const sessionCtx = new SessionContext();
|
|
285
|
+
|
|
286
|
+
// State engine — tracks internal dimensions over time
|
|
287
|
+
// Extract state dimensions (skip 'drives' — it's for the LLM, not the engine)
|
|
288
|
+
const rawState = personality?.state;
|
|
289
|
+
const stateConfig = rawState ? Object.fromEntries(
|
|
290
|
+
Object.entries(rawState).filter(([k]) => k !== 'drives')
|
|
291
|
+
) : null;
|
|
292
|
+
const stateEngine = new StateEngine(stateConfig);
|
|
293
|
+
|
|
294
|
+
// Personality layer (LLM agent) — requires character data + API key
|
|
295
|
+
const agentOpts = {
|
|
296
|
+
model: opts.model,
|
|
297
|
+
getState: () => stateEngine.active ? stateEngine.toJSON() : null,
|
|
298
|
+
};
|
|
299
|
+
function onAgentReaction(reaction) {
|
|
300
|
+
if (reaction.animation && animMap.has(reaction.animation)) {
|
|
301
|
+
switchAnimation(reaction.animation, ONE_SHOT_ANIMS.has(reaction.animation) ? 'one-shot' : 'loop');
|
|
302
|
+
}
|
|
303
|
+
if (reaction.speech) {
|
|
304
|
+
speechText = reaction.speech;
|
|
305
|
+
speechSetAt = Date.now();
|
|
306
|
+
lastSpeech = reaction.speech;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
let agent = character
|
|
310
|
+
? createAgent(character, [...animMap.keys()], onAgentReaction, agentOpts)
|
|
311
|
+
: null;
|
|
312
|
+
|
|
313
|
+
// Socket path
|
|
314
|
+
const sockPath = `/tmp/storymode-companion-${process.pid}.sock`;
|
|
315
|
+
|
|
316
|
+
// Clean up stale socket
|
|
317
|
+
if (existsSync(sockPath)) {
|
|
318
|
+
try { unlinkSync(sockPath); } catch { /* ignore */ }
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// --- Terminal setup ---
|
|
322
|
+
if (fullscreen) {
|
|
323
|
+
// Alt screen, hide cursor, clear
|
|
324
|
+
stdout.write('\x1b[?1049h\x1b[?25l\x1b[2J\x1b[H');
|
|
325
|
+
} else {
|
|
326
|
+
stdout.write('\x1b[?25l'); // hide cursor
|
|
327
|
+
// Reserve space
|
|
328
|
+
for (let i = 0; i < totalLines; i++) {
|
|
329
|
+
stdout.write('\n');
|
|
330
|
+
}
|
|
331
|
+
stdout.write(`\x1b[${totalLines}A`);
|
|
332
|
+
stdout.write('\x1b7'); // save cursor position
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function switchAnimation(animName, type) {
|
|
336
|
+
if (!animMap.has(animName)) {
|
|
337
|
+
// Fallback to idle if animation not found
|
|
338
|
+
if (animName === IDLE_ANIM) return;
|
|
339
|
+
animName = IDLE_ANIM;
|
|
340
|
+
type = 'loop';
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const data = animMap.get(animName);
|
|
344
|
+
currentAnimName = animName;
|
|
345
|
+
currentFrames = data.frames;
|
|
346
|
+
currentFps = data.fps || 16;
|
|
347
|
+
frameIdx = 0;
|
|
348
|
+
loopCount = 0;
|
|
349
|
+
|
|
350
|
+
if (type === 'one-shot' || ONE_SHOT_ANIMS.has(animName)) {
|
|
351
|
+
oneShotLoopsRemaining = 2; // play 2 loops for one-shot
|
|
352
|
+
} else {
|
|
353
|
+
oneShotLoopsRemaining = 0;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Track recent
|
|
357
|
+
recentAnims.push(animName);
|
|
358
|
+
if (recentAnims.length > 3) recentAnims.shift();
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Word-wrap text to fit inside a bubble of given inner width */
|
|
362
|
+
function wrapText(text, width) {
|
|
363
|
+
const words = text.split(' ');
|
|
364
|
+
const lines = [];
|
|
365
|
+
let line = '';
|
|
366
|
+
for (const word of words) {
|
|
367
|
+
if (line.length + word.length + (line ? 1 : 0) > width) {
|
|
368
|
+
if (line) lines.push(line);
|
|
369
|
+
line = word.slice(0, width); // truncate long words
|
|
370
|
+
} else {
|
|
371
|
+
line = line ? line + ' ' + word : word;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
if (line) lines.push(line);
|
|
375
|
+
return lines.slice(0, BUBBLE_MAX_LINES);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** Render a speech bubble as an array of strings */
|
|
379
|
+
function renderBubble(text, width) {
|
|
380
|
+
const innerW = width - 4; // │ + space + text + space + │
|
|
381
|
+
const wrapped = wrapText(text, innerW);
|
|
382
|
+
const lines = [];
|
|
383
|
+
lines.push(' \x1b[0m╭' + '─'.repeat(innerW + 2) + '╮');
|
|
384
|
+
for (const wl of wrapped) {
|
|
385
|
+
lines.push(' \x1b[0m│ ' + wl + ' '.repeat(innerW - wl.length) + ' │');
|
|
386
|
+
}
|
|
387
|
+
// Pad remaining bubble lines if fewer than max
|
|
388
|
+
for (let i = wrapped.length; i < BUBBLE_MAX_LINES; i++) {
|
|
389
|
+
lines.push(' \x1b[0m│' + ' '.repeat(innerW + 2) + '│');
|
|
390
|
+
}
|
|
391
|
+
lines.push(' \x1b[0m╰' + '─'.repeat(innerW + 2) + '╯');
|
|
392
|
+
return lines;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Estimate sprite width from first frame of idle animation
|
|
396
|
+
const spriteWidth = (() => {
|
|
397
|
+
const firstLine = idleData.frames[0]?.[0] || '';
|
|
398
|
+
// Strip ANSI escape sequences to get visible character count
|
|
399
|
+
return firstLine.replace(/\x1b\[[0-9;]*m/g, '').length;
|
|
400
|
+
})();
|
|
401
|
+
const bubbleWidth = Math.max(20, Math.min(spriteWidth, 60));
|
|
402
|
+
|
|
403
|
+
function drawFrame(idx) {
|
|
404
|
+
let out = fullscreen ? '\x1b[H' : '\x1b8'; // cursor home vs restore
|
|
405
|
+
const frameLines = currentFrames[idx] || [];
|
|
406
|
+
for (let i = 0; i < maxLines; i++) {
|
|
407
|
+
if (i < frameLines.length) {
|
|
408
|
+
out += frameLines[i];
|
|
409
|
+
}
|
|
410
|
+
out += '\x1b[K\n';
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Auto-clear speech after timeout
|
|
414
|
+
if (speechText && (Date.now() - speechSetAt > SPEECH_CLEAR_MS)) {
|
|
415
|
+
speechText = null;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Speech bubble or empty space
|
|
419
|
+
if (speechText) {
|
|
420
|
+
const bubble = renderBubble(speechText, bubbleWidth);
|
|
421
|
+
for (const bl of bubble) {
|
|
422
|
+
out += bl + '\x1b[K\n';
|
|
423
|
+
}
|
|
424
|
+
} else {
|
|
425
|
+
// Empty lines to keep layout stable
|
|
426
|
+
for (let i = 0; i < bubbleLines - 1; i++) {
|
|
427
|
+
out += '\x1b[K\n';
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Status line
|
|
432
|
+
const stateStr = stateEngine.active
|
|
433
|
+
? ' ' + Object.entries(stateEngine.toJSON()).map(([k, v]) => `${k.slice(0,3)}:${v}`).join(' ')
|
|
434
|
+
: '';
|
|
435
|
+
const keyHints = !agent && character && !opts.noAi ? '[a]=AI key [q]=quit' : '[q]=quit';
|
|
436
|
+
out += `\x1b[0m\x1b[K ${characterName} [${currentAnimName}]${stateStr} ${keyHints}\n`;
|
|
437
|
+
stdout.write(out);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// --- Socket server ---
|
|
441
|
+
const server = createServer((conn) => {
|
|
442
|
+
let buf = '';
|
|
443
|
+
conn.on('data', (chunk) => { buf += chunk.toString(); });
|
|
444
|
+
conn.on('end', () => {
|
|
445
|
+
// Process each line (usually just one)
|
|
446
|
+
for (const line of buf.split('\n').filter(Boolean)) {
|
|
447
|
+
try {
|
|
448
|
+
const msg = JSON.parse(line);
|
|
449
|
+
handleEvent(msg);
|
|
450
|
+
} catch {
|
|
451
|
+
// Ignore malformed messages
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
});
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
server.on('error', (err) => {
|
|
458
|
+
// If socket already in use, try to recover
|
|
459
|
+
if (err.code === 'EADDRINUSE') {
|
|
460
|
+
try { unlinkSync(sockPath); } catch { /* ignore */ }
|
|
461
|
+
server.listen(sockPath);
|
|
462
|
+
}
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
server.listen(sockPath);
|
|
466
|
+
|
|
467
|
+
function handleEvent(msg) {
|
|
468
|
+
lastEventTime = Date.now();
|
|
469
|
+
sleeping = false;
|
|
470
|
+
|
|
471
|
+
// Update session context
|
|
472
|
+
sessionCtx.update(msg);
|
|
473
|
+
|
|
474
|
+
// Update state engine
|
|
475
|
+
stateEngine.update(msg.event);
|
|
476
|
+
|
|
477
|
+
// Instant layer: static map (0ms)
|
|
478
|
+
const mapping = mapEventToAnimation(msg.event, animMap);
|
|
479
|
+
if (mapping) {
|
|
480
|
+
switchAnimation(mapping.anim, mapping.type);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// State layer: check thresholds (0ms, overrides instant layer)
|
|
484
|
+
if (stateEngine.active && personality?.animations) {
|
|
485
|
+
const triggered = stateEngine.checkThresholds(personality.animations);
|
|
486
|
+
if (triggered && animMap.has(triggered.animName)) {
|
|
487
|
+
switchAnimation(triggered.animName, triggered.type);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Speech bubble (static fallback)
|
|
492
|
+
const speech = pickSpeech(msg.event, lastSpeech);
|
|
493
|
+
if (speech) {
|
|
494
|
+
speechText = speech;
|
|
495
|
+
speechSetAt = Date.now();
|
|
496
|
+
lastSpeech = speech;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Personality layer: push to LLM agent (debounced, ~3s)
|
|
500
|
+
if (agent) {
|
|
501
|
+
const narrated = narrateEvent(msg);
|
|
502
|
+
agent.pushEvent(
|
|
503
|
+
{ summary: narrated, type: msg.event, tool: msg.tool_name || msg.tool },
|
|
504
|
+
sessionCtx.toJSON(),
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function cleanup() {
|
|
510
|
+
if (agent) agent.destroy();
|
|
511
|
+
if (stdin.isTTY) stdin.setRawMode(false);
|
|
512
|
+
stdin.removeAllListeners('data');
|
|
513
|
+
stdin.pause();
|
|
514
|
+
if (fullscreen) {
|
|
515
|
+
stdout.write('\x1b[?1049l\x1b[?25h'); // exit alt screen, show cursor
|
|
516
|
+
} else {
|
|
517
|
+
stdout.write('\x1b[?25h'); // show cursor
|
|
518
|
+
}
|
|
519
|
+
server.close();
|
|
520
|
+
try { unlinkSync(sockPath); } catch { /* ignore */ }
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
let promptingKey = false;
|
|
524
|
+
|
|
525
|
+
async function handleApiKeyPrompt() {
|
|
526
|
+
if (promptingKey || agent) return;
|
|
527
|
+
promptingKey = true;
|
|
528
|
+
|
|
529
|
+
// Temporarily exit raw mode for readline
|
|
530
|
+
if (stdin.isTTY) stdin.setRawMode(false);
|
|
531
|
+
stdin.removeAllListeners('data');
|
|
532
|
+
|
|
533
|
+
const key = await promptForApiKey('hotkey');
|
|
534
|
+
|
|
535
|
+
// Restore raw mode
|
|
536
|
+
if (stdin.isTTY) {
|
|
537
|
+
stdin.setRawMode(true);
|
|
538
|
+
stdin.resume();
|
|
539
|
+
stdin.on('data', handleKey);
|
|
540
|
+
}
|
|
541
|
+
promptingKey = false;
|
|
542
|
+
|
|
543
|
+
if (key && character) {
|
|
544
|
+
agent = createAgent(character, [...animMap.keys()], onAgentReaction, agentOpts);
|
|
545
|
+
if (agent) {
|
|
546
|
+
speechText = 'AI personality activated!';
|
|
547
|
+
speechSetAt = Date.now();
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function handleKey(data) {
|
|
553
|
+
const key = data.toString();
|
|
554
|
+
if (key === 'q' || key === '\x03') {
|
|
555
|
+
quit = true;
|
|
556
|
+
} else if (key === 'a' && !agent && character && !opts.noAi) {
|
|
557
|
+
handleApiKeyPrompt();
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
if (stdin.isTTY) {
|
|
562
|
+
stdin.setRawMode(true);
|
|
563
|
+
stdin.resume();
|
|
564
|
+
stdin.on('data', handleKey);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
return new Promise((resolve) => {
|
|
568
|
+
const interval = () => 1000 / currentFps;
|
|
569
|
+
|
|
570
|
+
function tick() {
|
|
571
|
+
if (quit) {
|
|
572
|
+
cleanup();
|
|
573
|
+
resolve();
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// State engine tick (passive drain over time)
|
|
578
|
+
stateEngine.tick();
|
|
579
|
+
|
|
580
|
+
// State-driven animation check (e.g. energy < 0.15 → sleep)
|
|
581
|
+
if (stateEngine.active && personality?.animations) {
|
|
582
|
+
const triggered = stateEngine.checkThresholds(personality.animations);
|
|
583
|
+
if (triggered && animMap.has(triggered.animName) && currentAnimName !== triggered.animName) {
|
|
584
|
+
switchAnimation(triggered.animName, triggered.type);
|
|
585
|
+
sleeping = triggered.animName === SLEEP_ANIM;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// Fallback sleep check (when no state engine or no energy dimension)
|
|
590
|
+
if (!sleeping) {
|
|
591
|
+
const idleTime = (Date.now() - lastEventTime) / 1000;
|
|
592
|
+
if (idleTime >= SLEEP_TIMEOUT_S) {
|
|
593
|
+
sleeping = true;
|
|
594
|
+
if (animMap.has(SLEEP_ANIM)) {
|
|
595
|
+
switchAnimation(SLEEP_ANIM, 'loop');
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
drawFrame(frameIdx);
|
|
601
|
+
frameIdx++;
|
|
602
|
+
|
|
603
|
+
if (frameIdx >= currentFrames.length) {
|
|
604
|
+
loopCount++;
|
|
605
|
+
frameIdx = 0;
|
|
606
|
+
|
|
607
|
+
// One-shot: count down loops, return to idle
|
|
608
|
+
if (oneShotLoopsRemaining > 0) {
|
|
609
|
+
oneShotLoopsRemaining--;
|
|
610
|
+
if (oneShotLoopsRemaining <= 0) {
|
|
611
|
+
switchAnimation(IDLE_ANIM, 'loop');
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
setTimeout(tick, interval());
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
tick();
|
|
620
|
+
|
|
621
|
+
// Print socket path so hooks can find it
|
|
622
|
+
process.stderr.write(` Socket: ${sockPath}\n`);
|
|
623
|
+
if (agent) {
|
|
624
|
+
const modelName = opts.model || 'claude-haiku-4-5';
|
|
625
|
+
process.stderr.write(` AI personality: ON (${modelName})\n`);
|
|
626
|
+
} else if (character && !opts.noAi) {
|
|
627
|
+
process.stderr.write(` AI personality: OFF — press [a] to add API key\n`);
|
|
628
|
+
} else if (opts.noAi) {
|
|
629
|
+
process.stderr.write(` AI personality: OFF (--no-ai)\n`);
|
|
630
|
+
}
|
|
631
|
+
process.stderr.write(` Listening for Claude Code events...\n`);
|
|
632
|
+
|
|
633
|
+
const sigHandler = () => { quit = true; };
|
|
634
|
+
process.on('SIGINT', sigHandler);
|
|
635
|
+
process.on('SIGTERM', sigHandler);
|
|
636
|
+
process.on('SIGHUP', sigHandler);
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
|
|
204
640
|
export function showFrame(framesData, info) {
|
|
205
641
|
const frames = framesData.frames;
|
|
206
642
|
if (!frames || frames.length === 0) {
|