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.
Files changed (54) hide show
  1. package/package.json +1 -1
  2. package/src/app.mjs +2 -2
  3. package/src/cli.mjs +1 -1
  4. package/src/repl.mjs +72 -6
  5. package/src/runtime/agent/orchestrator/session/context-utils.mjs +76 -21
  6. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +45 -4
  7. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +11 -1
  8. package/src/runtime/agent/orchestrator/session/store/summary-cache.mjs +36 -13
  9. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +83 -18
  10. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +14 -0
  11. package/src/runtime/agent/orchestrator/session/store.mjs +37 -2
  12. package/src/runtime/channels/lib/config.mjs +7 -0
  13. package/src/runtime/channels/lib/status-snapshot.mjs +6 -30
  14. package/src/runtime/channels/lib/webhook/relay-tunnel.mjs +203 -0
  15. package/src/runtime/channels/lib/webhook.mjs +23 -201
  16. package/src/runtime/shared/config.mjs +0 -9
  17. package/src/runtime/shared/time-format.mjs +56 -0
  18. package/src/runtime/shared/tool-card-model.mjs +740 -0
  19. package/src/session-runtime/channel-config-api.mjs +5 -0
  20. package/src/session-runtime/context-status.mjs +2 -1
  21. package/src/session-runtime/lifecycle-api.mjs +5 -0
  22. package/src/session-runtime/prewarm.mjs +13 -7
  23. package/src/session-runtime/provider-request-tools.mjs +6 -0
  24. package/src/session-runtime/runtime-core.mjs +28 -9
  25. package/src/session-runtime/session-text.mjs +6 -0
  26. package/src/session-runtime/settings-api.mjs +0 -12
  27. package/src/session-runtime/tool-catalog-schema.mjs +5 -1
  28. package/src/standalone/channel-admin.mjs +35 -21
  29. package/src/tui/App.jsx +0 -15
  30. package/src/tui/app/channel-pickers.mjs +18 -42
  31. package/src/tui/app/doctor.mjs +0 -1
  32. package/src/tui/app/transcript-window.mjs +16 -0
  33. package/src/tui/app/use-transcript-window.mjs +36 -1
  34. package/src/tui/components/Markdown.jsx +15 -1
  35. package/src/tui/components/Message.jsx +1 -1
  36. package/src/tui/components/PromptInput.jsx +7 -0
  37. package/src/tui/components/ToolExecution.jsx +47 -196
  38. package/src/tui/components/tool-execution/surface-detail.mjs +33 -394
  39. package/src/tui/components/tool-execution/text-format.mjs +27 -104
  40. package/src/tui/dist/index.mjs +613 -264
  41. package/src/tui/engine/frame-batched-store.mjs +5 -3
  42. package/src/tui/engine/live-share.mjs +9 -0
  43. package/src/tui/engine/render-timing.mjs +28 -0
  44. package/src/tui/engine/session-api-ext.mjs +7 -10
  45. package/src/tui/engine/tui-steering-persist.mjs +25 -4
  46. package/src/tui/engine.mjs +9 -1
  47. package/src/tui/index.jsx +11 -3
  48. package/src/tui/markdown/measure-rendered-rows.mjs +28 -16
  49. package/src/tui/markdown/render-ansi.mjs +34 -1
  50. package/src/tui/markdown/stream-fence.mjs +44 -7
  51. package/src/tui/markdown/streaming-markdown.mjs +95 -27
  52. package/src/tui/time-format.mjs +4 -51
  53. package/src/ui/stream-finalize.mjs +45 -0
  54. 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
- ngrokProcess = null;
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.startNgrok();
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
- * Check if a previous ngrok process can be reused.
369
- * Returns true if the existing ngrok is alive, healthy, and serving the right domain.
370
- * Returns false otherwise. External processes are never killed; stale or
371
- * incompatible metadata is ignored so another terminal's tunnel stays alive.
372
- */
373
- async reuseNgrokIfHealthy(domain, expectedPort = null) {
374
- const meta = readNgrokMeta();
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
- ngrokBin = resolveNgrokBin();
339
+ this.hookTunnel = startHookTunnel({ relayUrl, getLocalPort: () => this.boundPort });
340
+ logWebhook(`public hook base: ${this.hookTunnel.publicBase}`);
459
341
  } catch (err) {
460
- if (!this._ngrokDisabledLogged) {
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
- // Intentionally do NOT kill ngrok — let it survive across MCP restarts.
524
- // The next start() can reuse it if reuseNgrokIfHealthy() validates it.
525
- if (this.ngrokProcess) {
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 (ngrok left running for reuse)");
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.config.ngrokDomain) {
677
- return `https://${this.config.ngrokDomain}/webhook/${name}`;
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
+ }