mixdog 0.9.65 → 0.9.67
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/package.json +1 -1
- package/src/app.mjs +2 -2
- package/src/cli.mjs +1 -1
- package/src/repl.mjs +72 -6
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +76 -21
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +45 -4
- package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +11 -1
- package/src/runtime/agent/orchestrator/session/store/summary-cache.mjs +36 -13
- package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +83 -18
- package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +14 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +37 -2
- package/src/runtime/channels/lib/config.mjs +7 -0
- package/src/runtime/channels/lib/status-snapshot.mjs +6 -30
- package/src/runtime/channels/lib/webhook/relay-tunnel.mjs +203 -0
- package/src/runtime/channels/lib/webhook.mjs +23 -201
- package/src/runtime/shared/config.mjs +0 -9
- package/src/runtime/shared/time-format.mjs +56 -0
- package/src/runtime/shared/tool-card-model.mjs +740 -0
- package/src/session-runtime/channel-config-api.mjs +5 -0
- package/src/session-runtime/context-status.mjs +2 -1
- package/src/session-runtime/lifecycle-api.mjs +5 -0
- package/src/session-runtime/prewarm.mjs +13 -7
- package/src/session-runtime/provider-request-tools.mjs +6 -0
- package/src/session-runtime/runtime-core.mjs +28 -9
- package/src/session-runtime/session-text.mjs +6 -0
- package/src/session-runtime/settings-api.mjs +0 -12
- package/src/session-runtime/tool-catalog-schema.mjs +5 -1
- package/src/standalone/channel-admin.mjs +35 -21
- package/src/tui/App.jsx +0 -15
- package/src/tui/app/channel-pickers.mjs +18 -42
- package/src/tui/app/doctor.mjs +0 -1
- package/src/tui/app/transcript-window.mjs +16 -0
- package/src/tui/app/use-transcript-window.mjs +36 -1
- package/src/tui/components/Markdown.jsx +15 -1
- package/src/tui/components/Message.jsx +1 -1
- package/src/tui/components/PromptInput.jsx +7 -0
- package/src/tui/components/ToolExecution.jsx +47 -196
- package/src/tui/components/tool-execution/surface-detail.mjs +33 -394
- package/src/tui/components/tool-execution/text-format.mjs +27 -104
- package/src/tui/dist/index.mjs +613 -264
- package/src/tui/engine/frame-batched-store.mjs +5 -3
- package/src/tui/engine/live-share.mjs +9 -0
- package/src/tui/engine/render-timing.mjs +28 -0
- package/src/tui/engine/session-api-ext.mjs +7 -10
- package/src/tui/engine/tui-steering-persist.mjs +25 -4
- package/src/tui/engine.mjs +9 -1
- package/src/tui/index.jsx +11 -3
- package/src/tui/markdown/measure-rendered-rows.mjs +28 -16
- package/src/tui/markdown/render-ansi.mjs +34 -1
- package/src/tui/markdown/stream-fence.mjs +44 -7
- package/src/tui/markdown/streaming-markdown.mjs +95 -27
- package/src/tui/time-format.mjs +4 -51
- package/src/ui/stream-finalize.mjs +45 -0
- package/src/runtime/channels/lib/webhook/ngrok.mjs +0 -181
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
import * as http from "http";
|
|
2
|
-
import { spawn, spawnSync } from "child_process";
|
|
3
2
|
import { randomUUID } from "crypto";
|
|
4
|
-
import { getWebhookAuthtoken } from "../../shared/config.mjs";
|
|
5
3
|
import { logWebhook } from "./webhook/log.mjs";
|
|
6
4
|
import { SIGNATURE_HEADERS, extractSignature, STRIPE_TOLERANCE_MS, verifySignature } from "./webhook/signature.mjs";
|
|
7
|
-
import { detachedSpawnOpts } from "../../shared/spawn-flags.mjs";
|
|
8
5
|
import {
|
|
9
6
|
loadEndpointConfig,
|
|
10
7
|
readEndpointSecret,
|
|
@@ -15,20 +12,7 @@ import {
|
|
|
15
12
|
extractDeliveryId,
|
|
16
13
|
buildHeadersSummary,
|
|
17
14
|
} from "./webhook/deliveries.mjs";
|
|
18
|
-
import {
|
|
19
|
-
NGROK_MAX_AGE_MS,
|
|
20
|
-
resolveNgrokBin,
|
|
21
|
-
normalizeDomain,
|
|
22
|
-
readNgrokMeta,
|
|
23
|
-
writeNgrokMeta,
|
|
24
|
-
clearNgrokMeta,
|
|
25
|
-
isLikelyNgrok,
|
|
26
|
-
isProcessAlive,
|
|
27
|
-
parseStrictPidLine,
|
|
28
|
-
resolvePortOwnerPid,
|
|
29
|
-
handleWebhookPortInUse,
|
|
30
|
-
checkNgrokHealth,
|
|
31
|
-
} from "./webhook/ngrok.mjs";
|
|
15
|
+
import { resolveHookRelayUrl, startHookTunnel } from "./webhook/relay-tunnel.mjs";
|
|
32
16
|
|
|
33
17
|
class WebhookServer {
|
|
34
18
|
config;
|
|
@@ -38,7 +22,7 @@ class WebhookServer {
|
|
|
38
22
|
boundPort = 0;
|
|
39
23
|
listenInFlight = false;
|
|
40
24
|
noSecretWarned = false;
|
|
41
|
-
|
|
25
|
+
hookTunnel = null;
|
|
42
26
|
constructor(config) {
|
|
43
27
|
this.config = config;
|
|
44
28
|
}
|
|
@@ -311,42 +295,19 @@ class WebhookServer {
|
|
|
311
295
|
const basePort = this.config.port || 3333;
|
|
312
296
|
const maxPort = basePort + 7;
|
|
313
297
|
let currentPort = basePort;
|
|
314
|
-
let baseReclaimAttempted = false;
|
|
315
298
|
const tryListen = () => {
|
|
316
299
|
this.server.listen(currentPort, () => {
|
|
317
300
|
this.listenInFlight = false;
|
|
318
301
|
this.boundPort = currentPort;
|
|
319
302
|
logWebhook(`listening on port ${currentPort}`);
|
|
320
|
-
this.
|
|
303
|
+
this._startHookTunnel();
|
|
321
304
|
});
|
|
322
305
|
};
|
|
323
306
|
this.server.on("error", (err) => {
|
|
324
|
-
if (err.code === "EADDRINUSE" && currentPort === basePort && !baseReclaimAttempted) {
|
|
325
|
-
baseReclaimAttempted = true;
|
|
326
|
-
void handleWebhookPortInUse(basePort, this.config.ngrokDomain || this.config.domain).then((result) => {
|
|
327
|
-
if (result.ok) {
|
|
328
|
-
currentPort = basePort;
|
|
329
|
-
logWebhook(`reclaimed base port ${basePort}, retrying bind`);
|
|
330
|
-
tryListen();
|
|
331
|
-
return;
|
|
332
|
-
}
|
|
333
|
-
if (result.bump && currentPort < maxPort) {
|
|
334
|
-
logWebhook(
|
|
335
|
-
`port ${basePort} not reclaimable (live non-ngrok PID ${result.ownerPid ?? "unknown"}), trying ${currentPort + 1}`,
|
|
336
|
-
);
|
|
337
|
-
currentPort++;
|
|
338
|
-
tryListen();
|
|
339
|
-
return;
|
|
340
|
-
}
|
|
341
|
-
if (err.code === "EADDRINUSE") {
|
|
342
|
-
logWebhook(`all ports ${basePort}-${maxPort} in use \u2014 webhook server disabled`);
|
|
343
|
-
this.listenInFlight = false;
|
|
344
|
-
this.server = null;
|
|
345
|
-
}
|
|
346
|
-
});
|
|
347
|
-
return;
|
|
348
|
-
}
|
|
349
307
|
if (err.code === "EADDRINUSE" && currentPort < maxPort) {
|
|
308
|
+
// The relay tunnel forwards to whatever port this process binds, so
|
|
309
|
+
// port identity no longer matters (the ngrok-era domain↔port coupling
|
|
310
|
+
// is gone) — walk up the range.
|
|
350
311
|
logWebhook(`port ${currentPort} already in use, trying ${currentPort + 1}`);
|
|
351
312
|
currentPort++;
|
|
352
313
|
tryListen();
|
|
@@ -364,166 +325,27 @@ class WebhookServer {
|
|
|
364
325
|
});
|
|
365
326
|
tryListen();
|
|
366
327
|
}
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
if (!meta || !(meta.pid > 0)) {
|
|
376
|
-
clearNgrokMeta();
|
|
377
|
-
return false;
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
const { pid } = meta;
|
|
381
|
-
|
|
382
|
-
// Metadata domain mismatch — different config. Do not kill another terminal's tunnel.
|
|
383
|
-
if (meta.domain && normalizeDomain(meta.domain) !== normalizeDomain(domain)) {
|
|
384
|
-
logWebhook(`ngrok meta domain mismatch (${meta.domain} vs ${domain}), ignoring PID ${pid}`);
|
|
385
|
-
clearNgrokMeta();
|
|
386
|
-
return false;
|
|
387
|
-
}
|
|
388
|
-
if (expectedPort && meta.port && Number(meta.port) !== Number(expectedPort)) {
|
|
389
|
-
// A tunnel forwarding to the OLD local port cannot serve this server.
|
|
390
|
-
// Ignore the metadata and let this process try its own tunnel without
|
|
391
|
-
// touching the existing process.
|
|
392
|
-
logWebhook(`ngrok meta port mismatch (${meta.port} vs ${expectedPort}) — ignoring PID ${pid}`);
|
|
393
|
-
clearNgrokMeta();
|
|
394
|
-
return false;
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
// Stale check — older than 24 hours (ngrok session realistic lifetime;
|
|
398
|
-
// ngrok free-tier tunnels expire after ~2h but paid/reserved-domain
|
|
399
|
-
// tunnels survive much longer; 24h is a safe conservative ceiling).
|
|
400
|
-
if (meta.startedAt && (Date.now() - new Date(meta.startedAt).getTime()) > NGROK_MAX_AGE_MS) {
|
|
401
|
-
logWebhook(`ngrok meta stale (started ${meta.startedAt}), ignoring PID ${pid}`);
|
|
402
|
-
clearNgrokMeta();
|
|
403
|
-
return false;
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
// Check if process is alive
|
|
407
|
-
let alive = false;
|
|
408
|
-
try { process.kill(pid, 0); alive = true } catch {}
|
|
409
|
-
|
|
410
|
-
if (!alive) {
|
|
411
|
-
logWebhook(`ngrok PID ${pid} is dead, cleaning up`);
|
|
412
|
-
clearNgrokMeta();
|
|
413
|
-
return false;
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
// Process alive + domain matches — verify tunnel via 4040 API
|
|
417
|
-
const healthy = await checkNgrokHealth(domain, expectedPort);
|
|
418
|
-
if (healthy) {
|
|
419
|
-
logWebhook(`reusing ngrok (PID ${pid}, domain ${domain}, port ${meta.port})`);
|
|
420
|
-
return true;
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
// Alive but tunnel unhealthy. Leave it alone; it may belong to another terminal.
|
|
424
|
-
logWebhook(`ngrok PID ${pid} alive but tunnel unhealthy, ignoring`);
|
|
425
|
-
clearNgrokMeta();
|
|
426
|
-
return false;
|
|
427
|
-
}
|
|
428
|
-
async startNgrok() {
|
|
429
|
-
// Mutex: skip only when THIS process still owns a live ngrok child. Fresh
|
|
430
|
-
// daemon restarts always have ngrokProcess=null and must proceed; stale
|
|
431
|
-
// in-memory refs after exit must not block respawn.
|
|
432
|
-
if (this.ngrokProcess && this.ngrokProcess.exitCode == null && !this.ngrokProcess.killed) return;
|
|
433
|
-
if (this._ngrokStartPromise) return this._ngrokStartPromise;
|
|
434
|
-
this._ngrokStartPromise = this._doStartNgrok();
|
|
435
|
-
try { await this._ngrokStartPromise; } finally { this._ngrokStartPromise = null; }
|
|
436
|
-
}
|
|
437
|
-
async _doStartNgrok() {
|
|
438
|
-
const authtoken = getWebhookAuthtoken();
|
|
439
|
-
const domain = this.config.ngrokDomain || this.config.domain;
|
|
440
|
-
if (!authtoken || !domain) return;
|
|
441
|
-
let attempts = 0;
|
|
442
|
-
while (!this.boundPort) {
|
|
443
|
-
if (++attempts > 30) {
|
|
444
|
-
logWebhook("ngrok: gave up waiting for port");
|
|
445
|
-
return;
|
|
446
|
-
}
|
|
447
|
-
await new Promise((r) => setTimeout(r, 500));
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
// Try to reuse an existing ngrok process
|
|
451
|
-
const reused = await this.reuseNgrokIfHealthy(domain, this.boundPort);
|
|
452
|
-
if (reused) {
|
|
328
|
+
// Relay-backed public tunnel (replaces the ngrok child): one outbound
|
|
329
|
+
// WebSocket to the Mixdog relay serves every inbound webhook — no binary,
|
|
330
|
+
// no authtoken, no reserved domain.
|
|
331
|
+
_startHookTunnel() {
|
|
332
|
+
if (this.hookTunnel) return;
|
|
333
|
+
const relayUrl = resolveHookRelayUrl();
|
|
334
|
+
if (!relayUrl) {
|
|
335
|
+
logWebhook("hook tunnel disabled (MIXDOG_RELAY_URL=off)");
|
|
453
336
|
return;
|
|
454
337
|
}
|
|
455
|
-
|
|
456
|
-
let ngrokBin;
|
|
457
338
|
try {
|
|
458
|
-
|
|
339
|
+
this.hookTunnel = startHookTunnel({ relayUrl, getLocalPort: () => this.boundPort });
|
|
340
|
+
logWebhook(`public hook base: ${this.hookTunnel.publicBase}`);
|
|
459
341
|
} catch (err) {
|
|
460
|
-
|
|
461
|
-
logWebhook(`ngrok disabled — ${err.message}`);
|
|
462
|
-
this._ngrokDisabledLogged = true;
|
|
463
|
-
}
|
|
464
|
-
return;
|
|
465
|
-
}
|
|
466
|
-
spawnSync(ngrokBin, ["config", "add-authtoken", authtoken], { stdio: "ignore", timeout: 1e4, windowsHide: true });
|
|
467
|
-
attempts = 0;
|
|
468
|
-
const waitAndStart = () => {
|
|
469
|
-
if (!this.boundPort) {
|
|
470
|
-
if (++attempts > 30) {
|
|
471
|
-
logWebhook("ngrok: gave up waiting for port");
|
|
472
|
-
return;
|
|
473
|
-
}
|
|
474
|
-
setTimeout(waitAndStart, 500);
|
|
475
|
-
return;
|
|
476
|
-
}
|
|
477
|
-
try {
|
|
478
|
-
// stdio fully ignored so Node does not pass inheritable stdio handles
|
|
479
|
-
// (bInheritHandles stays false on Windows). There is no portable Node API
|
|
480
|
-
// to mark the http.Server listen socket non-inheritable; detached ngrok
|
|
481
|
-
// can still inherit stale handles in edge cases — layer-1 port reclaim
|
|
482
|
-
// on EADDRINUSE is the guaranteed safety net.
|
|
483
|
-
this.ngrokProcess = spawn(ngrokBin, ["http", String(this.boundPort), "--url=" + domain], {
|
|
484
|
-
stdio: ["ignore", "ignore", "ignore"],
|
|
485
|
-
...detachedSpawnOpts,
|
|
486
|
-
});
|
|
487
|
-
this.ngrokProcess.unref();
|
|
488
|
-
if (this.ngrokProcess.pid) {
|
|
489
|
-
writeNgrokMeta({
|
|
490
|
-
pid: this.ngrokProcess.pid,
|
|
491
|
-
domain,
|
|
492
|
-
port: this.boundPort,
|
|
493
|
-
startedAt: new Date().toISOString(),
|
|
494
|
-
binaryPath: ngrokBin,
|
|
495
|
-
});
|
|
496
|
-
}
|
|
497
|
-
this.ngrokProcess.on("exit", () => {
|
|
498
|
-
this.ngrokProcess = null;
|
|
499
|
-
clearNgrokMeta();
|
|
500
|
-
});
|
|
501
|
-
this.ngrokProcess.on("error", () => {
|
|
502
|
-
this.ngrokProcess = null;
|
|
503
|
-
clearNgrokMeta();
|
|
504
|
-
});
|
|
505
|
-
logWebhook(`ngrok tunnel started: ${domain} \u2192 localhost:${this.boundPort} (PID ${this.ngrokProcess.pid})`);
|
|
506
|
-
} catch (e) {
|
|
507
|
-
logWebhook(`ngrok start failed: ${e}`);
|
|
508
|
-
}
|
|
509
|
-
};
|
|
510
|
-
setTimeout(waitAndStart, 1e3);
|
|
511
|
-
// Hold the outer startNgrok() mutex (`_ngrokStartPromise`) until
|
|
512
|
-
// waitAndStart actually spawns ngrok OR exhausts its 30-attempt
|
|
513
|
-
// budget. Pre-fix the mutex released as soon as the setTimeout was
|
|
514
|
-
// scheduled, letting a duplicate startNgrok() call within the wait
|
|
515
|
-
// window arm a second timer and spawn a second ngrok process.
|
|
516
|
-
// Deadline: 1s initial + 30 × 500ms attempts = 16s, +1.5s slack.
|
|
517
|
-
const _deadline = Date.now() + 17500;
|
|
518
|
-
while (!this.ngrokProcess && Date.now() < _deadline) {
|
|
519
|
-
await new Promise((r) => setTimeout(r, 100));
|
|
342
|
+
logWebhook(`hook tunnel start failed: ${err?.message || err}`);
|
|
520
343
|
}
|
|
521
344
|
}
|
|
522
345
|
stop() {
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
this.ngrokProcess = null;
|
|
346
|
+
if (this.hookTunnel) {
|
|
347
|
+
try { this.hookTunnel.close(); } catch { /* already closed */ }
|
|
348
|
+
this.hookTunnel = null;
|
|
527
349
|
}
|
|
528
350
|
let closed = Promise.resolve();
|
|
529
351
|
if (this.server) {
|
|
@@ -538,7 +360,7 @@ class WebhookServer {
|
|
|
538
360
|
}
|
|
539
361
|
});
|
|
540
362
|
}
|
|
541
|
-
logWebhook("stopped
|
|
363
|
+
logWebhook("stopped");
|
|
542
364
|
return closed;
|
|
543
365
|
}
|
|
544
366
|
// reloadConfig(webhookCfg, options?)
|
|
@@ -673,8 +495,8 @@ ${payload}
|
|
|
673
495
|
}
|
|
674
496
|
/** Get the webhook URL for an endpoint name */
|
|
675
497
|
getUrl(name) {
|
|
676
|
-
if (this.
|
|
677
|
-
return
|
|
498
|
+
if (this.hookTunnel) {
|
|
499
|
+
return `${this.hookTunnel.publicBase}/webhook/${name}`;
|
|
678
500
|
}
|
|
679
501
|
return `http://localhost:${this.boundPort || this.config.port}/webhook/${name}`;
|
|
680
502
|
}
|
|
@@ -437,7 +437,6 @@ function getCapabilities() {
|
|
|
437
437
|
export const SECRET_ACCOUNTS = Object.freeze({
|
|
438
438
|
discordToken: 'discord.token',
|
|
439
439
|
telegramToken: 'telegram.token',
|
|
440
|
-
webhookAuth: 'webhook.authtoken',
|
|
441
440
|
agentApiKey: (provider) => `agent.${provider}.apiKey`,
|
|
442
441
|
openaiUsageSessionKey: 'agent.openai.usageSessionKey',
|
|
443
442
|
opencodeGoAuthCookie: 'agent.opencode-go.authCookie',
|
|
@@ -497,14 +496,6 @@ export function getTelegramToken() {
|
|
|
497
496
|
return _readSecret(SECRET_ACCOUNTS.telegramToken)
|
|
498
497
|
}
|
|
499
498
|
|
|
500
|
-
/**
|
|
501
|
-
* Returns the ngrok/webhook authtoken.
|
|
502
|
-
* Priority: MIXDOG_WEBHOOK_AUTHTOKEN → keychain('webhook.authtoken') → null
|
|
503
|
-
*/
|
|
504
|
-
export function getWebhookAuthtoken() {
|
|
505
|
-
return _readSecret(SECRET_ACCOUNTS.webhookAuth)
|
|
506
|
-
}
|
|
507
|
-
|
|
508
499
|
export function getOpenAIUsageSessionKey() {
|
|
509
500
|
return process.env.OPENAI_USAGE_SESSION_KEY
|
|
510
501
|
|| process.env.OPENAI_DASHBOARD_SESSION_KEY
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* runtime/shared/time-format.mjs — compact elapsed-time labels shared by every
|
|
3
|
+
* surface (TUI, terminal, desktop renderer). Moved verbatim from
|
|
4
|
+
* src/tui/time-format.mjs so tool-card detail rows ("Running · 12s") derive
|
|
5
|
+
* from ONE formatter; the TUI module re-exports from here.
|
|
6
|
+
*
|
|
7
|
+
* Examples:
|
|
8
|
+
* 42s
|
|
9
|
+
* 9m 23s
|
|
10
|
+
* 1h 2m 3s
|
|
11
|
+
* 1d 3h 20m
|
|
12
|
+
*/
|
|
13
|
+
export function formatDuration(ms, options = {}) {
|
|
14
|
+
if (!Number.isFinite(Number(ms))) return '';
|
|
15
|
+
const value = Math.max(0, Number(ms) || 0);
|
|
16
|
+
if (value < 60_000) {
|
|
17
|
+
if (value < 1_000) return '';
|
|
18
|
+
return `${Math.floor(value / 1000)}s`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let days = Math.floor(value / 86_400_000);
|
|
22
|
+
let hours = Math.floor((value % 86_400_000) / 3_600_000);
|
|
23
|
+
let minutes = Math.floor((value % 3_600_000) / 60_000);
|
|
24
|
+
const seconds = Math.floor((value % 60_000) / 1000);
|
|
25
|
+
|
|
26
|
+
if (options.mostSignificantOnly) {
|
|
27
|
+
if (days > 0) return `${days}d`;
|
|
28
|
+
if (hours > 0) return `${hours}h`;
|
|
29
|
+
if (minutes > 0) return `${minutes}m`;
|
|
30
|
+
return `${seconds}s`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const hide = options.hideTrailingZeros;
|
|
34
|
+
if (days > 0) {
|
|
35
|
+
if (hide && hours === 0 && minutes === 0) return `${days}d`;
|
|
36
|
+
if (hide && minutes === 0) return `${days}d ${hours}h`;
|
|
37
|
+
return `${days}d ${hours}h ${minutes}m`;
|
|
38
|
+
}
|
|
39
|
+
if (hours > 0) {
|
|
40
|
+
if (hide && minutes === 0 && seconds === 0) return `${hours}h`;
|
|
41
|
+
if (hide && seconds === 0) return `${hours}h ${minutes}m`;
|
|
42
|
+
return `${hours}h ${minutes}m ${seconds}s`;
|
|
43
|
+
}
|
|
44
|
+
if (minutes > 0) {
|
|
45
|
+
if (hide && seconds === 0) return `${minutes}m`;
|
|
46
|
+
return `${minutes}m ${seconds}s`;
|
|
47
|
+
}
|
|
48
|
+
return `${seconds}s`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function formatElapsed(ms) {
|
|
52
|
+
const n = Math.max(0, Number(ms || 0));
|
|
53
|
+
if (!Number.isFinite(n) || n <= 0) return '';
|
|
54
|
+
if (n < 1000) return '';
|
|
55
|
+
return formatDuration(n);
|
|
56
|
+
}
|