pikiloom 0.4.21 → 0.4.22

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 (41) hide show
  1. package/dashboard/dist/assets/{AgentTab-Crg9GEUv.js → AgentTab-G0uiUjBk.js} +1 -1
  2. package/dashboard/dist/assets/ConnectionModal-BQvV5ok2.js +1 -0
  3. package/dashboard/dist/assets/{DirBrowser-Do4XzF2X.js → DirBrowser-BGacUCRb.js} +1 -1
  4. package/dashboard/dist/assets/ExtensionsTab-Bd2rjOVu.js +1 -0
  5. package/dashboard/dist/assets/{IMAccessTab-BX90Aiuq.js → IMAccessTab-BD-ZwAOb.js} +1 -1
  6. package/dashboard/dist/assets/{Modal-Dd5twuoE.js → Modal-BwmINu44.js} +1 -1
  7. package/dashboard/dist/assets/{Modals--Xl6dsUf.js → Modals-ncL7g7fW.js} +1 -1
  8. package/dashboard/dist/assets/{Select-Bp6SwWv6.js → Select-D7Iz4BLf.js} +1 -1
  9. package/dashboard/dist/assets/{SessionPanel-CHaOUor-.js → SessionPanel-zxiA8Pfd.js} +1 -1
  10. package/dashboard/dist/assets/{SystemTab-MVpMgFFD.js → SystemTab-DZjEZmVB.js} +1 -1
  11. package/dashboard/dist/assets/{index-7pyJSn4B.js → index-CTHVuMnd.js} +17 -17
  12. package/dashboard/dist/assets/index-CyCspgtc.css +1 -0
  13. package/dashboard/dist/assets/index-DEvh1R9o.js +3 -0
  14. package/dashboard/dist/assets/{shared-CxR9M3rn.js → shared-D5VHAaFg.js} +1 -1
  15. package/dashboard/dist/index.html +2 -2
  16. package/dist/agent/index.js +1 -1
  17. package/dist/agent/skill-installer.js +95 -0
  18. package/dist/catalog/skill-repos.js +12 -0
  19. package/dist/cli/main.js +31 -1
  20. package/dist/core/secrets/ref.js +8 -0
  21. package/dist/core/secrets/store.js +31 -11
  22. package/dist/dashboard/routes/extensions.js +137 -24
  23. package/dist/dashboard/server.js +27 -64
  24. package/dist/pikichannel/adapter-pikiloom.js +220 -0
  25. package/dist/pikichannel/code.js +35 -0
  26. package/dist/pikichannel/codec.js +50 -0
  27. package/dist/pikichannel/host.js +252 -0
  28. package/dist/pikichannel/protocol.js +105 -0
  29. package/dist/pikichannel/rendezvous-broker.js +114 -0
  30. package/dist/pikichannel/rendezvous-host.js +138 -0
  31. package/dist/pikichannel/server.js +284 -0
  32. package/dist/pikichannel/transport.js +49 -0
  33. package/dist/pikichannel/transports/webrtc-host.js +61 -0
  34. package/dist/pikichannel/transports/webrtc-shared.js +132 -0
  35. package/dist/pikichannel/transports/websocket-host.js +78 -0
  36. package/dist/pikichannel/web/demo.html +246 -0
  37. package/dist/pikichannel/web/sdk.js +361 -0
  38. package/package.json +4 -2
  39. package/dashboard/dist/assets/ExtensionsTab-j77Yqoz1.js +0 -1
  40. package/dashboard/dist/assets/index-BiPI4uFE.js +0 -3
  41. package/dashboard/dist/assets/index-dzfjF9Js.css +0 -1
package/dist/cli/main.js CHANGED
@@ -20,6 +20,7 @@ import { hasConfiguredChannelToken, resolveConfiguredChannels } from './channels
20
20
  import { ChannelSupervisor } from './channel-supervisor.js';
21
21
  import { listAgents } from '../agent/index.js';
22
22
  import { startDashboard } from '../dashboard/server.js';
23
+ import { buildServerCode } from '../pikichannel/code.js';
23
24
  import { buildSetupGuide, collectSetupState, hasReadyAgent, isSetupReady } from './onboarding.js';
24
25
  import { buildRestartCommand, clearDaemonPidFile, clearRestartStateFile, consumeRestartStateFile, createRestartStateFilePath, isProcessAlive, PROCESS_RESTART_EXIT_CODE, readDaemonPidFile, requestProcessRestart, writeDaemonPidFile, } from '../core/process-control.js';
25
26
  import { runSetupWizard } from './setup-wizard.js';
@@ -185,6 +186,9 @@ function parseArgs(argv) {
185
186
  case '--no-dashboard':
186
187
  args.noDashboard = true;
187
188
  break;
189
+ case '--server':
190
+ args.server = true;
191
+ break;
188
192
  case '--dashboard-port':
189
193
  args.dashboardPort = parseInt(it.next().value ?? '', 10);
190
194
  break;
@@ -254,6 +258,7 @@ Options:
254
258
  --timeout <seconds> Max seconds per agent request [default: ${DEFAULT_RUN_TIMEOUT_S}]
255
259
  --doctor Run setup checks and exit
256
260
  --setup Run the interactive setup wizard
261
+ --server Headless server: keep the host running, don't open a browser, print a connection code
257
262
  --no-daemon Disable watchdog (auto-restart on crash is ON by default)
258
263
  --no-dashboard Skip the web dashboard
259
264
  --dashboard-port <port> Dashboard port [default: 3939]
@@ -521,13 +526,16 @@ async function runSetupPhase(args, userConfig, configOverrides, channels, channe
521
526
  // Suppress the browser pop on auto-start when there's no user-facing
522
527
  // terminal: launchd-spawned bots, Docker/headless server runs, or when
523
528
  // the user explicitly set PIKILOOM_OPEN_BROWSER=0.
524
- const openBrowser = !process.env[FROM_LAUNCHD_ENV]
529
+ const openBrowser = !args.server
530
+ && !process.env[FROM_LAUNCHD_ENV]
525
531
  && !envBool('PIKILOOM_DOCKER', false)
526
532
  && envBool('PIKILOOM_OPEN_BROWSER', true);
527
533
  dashboard = await startDashboard({
528
534
  port: args.dashboardPort || 3939,
529
535
  open: openBrowser,
530
536
  });
537
+ if (args.server)
538
+ printServerConnectionCode(dashboard);
531
539
  if (needsSetup) {
532
540
  const ctx = { userConfig, configOverrides, args };
533
541
  const resolved = await awaitDashboardConfig(dashboard, ctx);
@@ -560,6 +568,28 @@ async function runSetupPhase(args, userConfig, configOverrides, channels, channe
560
568
  }
561
569
  return { dashboard, userConfig, channels, channel };
562
570
  }
571
+ /** Print the shareable connection code for `--server` (headless) mode. */
572
+ function printServerConnectionCode(dashboard) {
573
+ const c = loadUserConfig();
574
+ const sc = buildServerCode({
575
+ token: process.env.PIKICHANNEL_TOKEN || c.pikichannelToken,
576
+ nodeId: c.pikichannelNodeId,
577
+ publicHost: process.env.PIKICHANNEL_PUBLIC_HOST || c.pikichannelPublicHost,
578
+ rendezvous: process.env.PIKICHANNEL_RENDEZVOUS || c.pikichannelRendezvous,
579
+ });
580
+ const ts = new Date().toTimeString().slice(0, 8);
581
+ process.stdout.write(`\n[pikiloom ${ts}] server mode — host running, browser not opened\n`);
582
+ process.stdout.write(` local console: ${dashboard.url}\n`);
583
+ if (sc.mode === 'none') {
584
+ process.stdout.write(' to let others connect: set a public address (env PIKICHANNEL_PUBLIC_HOST,\n');
585
+ process.stdout.write(' or the dashboard 连接 → 分享 panel), or enable internet access, then restart.\n\n');
586
+ }
587
+ else {
588
+ process.stdout.write(` connection code (${sc.mode === 'direct' ? 'direct → ' : 'NAT via '}${sc.detail}):\n`);
589
+ process.stdout.write(` ${sc.code}\n`);
590
+ process.stdout.write(' paste it into a client → 连接 → 互联网/局域网.\n\n');
591
+ }
592
+ }
563
593
  /* ── Phase: post-setup validation ─────────────────────────────────── */
564
594
  /**
565
595
  * Re-resolve channels after the setup phase and validate we have a runnable
@@ -10,6 +10,14 @@
10
10
  */
11
11
  /** Stable service name used when writing to the OS keychain. */
12
12
  export const KEYCHAIN_SERVICE = 'pikiloom';
13
+ /**
14
+ * Legacy keychain service names this build still reads from. The project was
15
+ * renamed (pikiclaw → pikiloom); credentials written under the old service are
16
+ * orphaned because lookups now use `KEYCHAIN_SERVICE`. `readKeychain` falls
17
+ * back to these on a miss and lazily re-writes the secret under the current
18
+ * service, so each orphaned item self-heals on first use.
19
+ */
20
+ export const LEGACY_KEYCHAIN_SERVICES = ['pikiclaw'];
13
21
  export function isCredentialRef(value) {
14
22
  if (!value || typeof value !== 'object')
15
23
  return false;
@@ -2,7 +2,7 @@
2
2
  * OS keychain access — lazy import of @napi-rs/keyring (optional dep).
3
3
  * If keychain is unavailable, callers fall back to inline-seal.
4
4
  */
5
- import { KEYCHAIN_SERVICE } from './ref.js';
5
+ import { KEYCHAIN_SERVICE, LEGACY_KEYCHAIN_SERVICES } from './ref.js';
6
6
  let keyringModule; // undefined = not tried, null = failed
7
7
  async function loadKeyring() {
8
8
  if (keyringModule !== undefined)
@@ -30,17 +30,37 @@ export async function readKeychain(account) {
30
30
  const mod = await loadKeyring();
31
31
  if (!mod)
32
32
  throw new Error('OS keychain unavailable (install @napi-rs/keyring)');
33
- try {
34
- const entry = new mod.Entry(KEYCHAIN_SERVICE, account);
35
- const value = entry.getPassword();
36
- return typeof value === 'string' && value.length > 0 ? value : null;
37
- }
38
- catch (e) {
39
- // keyring-rs returns NoEntry as an error — treat as missing
40
- if (/NoEntry|no.such|not.found/i.test(e?.message || ''))
41
- return null;
42
- throw e;
33
+ const readUnder = (service) => {
34
+ try {
35
+ const entry = new mod.Entry(service, account);
36
+ const value = entry.getPassword();
37
+ return typeof value === 'string' && value.length > 0 ? value : null;
38
+ }
39
+ catch (e) {
40
+ // keyring-rs returns NoEntry as an error — treat as missing
41
+ if (/NoEntry|no.such|not.found/i.test(e?.message || ''))
42
+ return null;
43
+ throw e;
44
+ }
45
+ };
46
+ const current = readUnder(KEYCHAIN_SERVICE);
47
+ if (current != null)
48
+ return current;
49
+ // Pre-rename fallback: recover orphaned items and migrate them forward so the
50
+ // legacy lookup only ever happens once per account.
51
+ for (const legacy of LEGACY_KEYCHAIN_SERVICES) {
52
+ const value = readUnder(legacy);
53
+ if (value == null)
54
+ continue;
55
+ try {
56
+ new mod.Entry(KEYCHAIN_SERVICE, account).setPassword(value);
57
+ }
58
+ catch {
59
+ // best-effort migration — return the recovered secret regardless
60
+ }
61
+ return value;
43
62
  }
63
+ return null;
44
64
  }
45
65
  export async function writeKeychain(account, value) {
46
66
  const mod = await loadKeyring();
@@ -13,7 +13,7 @@
13
13
  */
14
14
  import { Hono } from 'hono';
15
15
  import { execFile } from 'node:child_process';
16
- import { addGlobalMcpExtension, removeGlobalMcpExtension, updateGlobalMcpExtension, addWorkspaceMcpExtension, removeWorkspaceMcpExtension, updateWorkspaceMcpExtension, getCatalogItems, buildInstalledConfigFromRecommended, checkMcpHealth, getCachedHealth, cacheHealth, getRecommendedMcpServer, listSkills, installSkill, removeSkill, getRecommendedSkillRepos, searchSkillRepos, searchMcpServers, startAuthorization, completeAuthorization, deleteMcpToken, getMcpToken, } from '../../agent/index.js';
16
+ import { addGlobalMcpExtension, removeGlobalMcpExtension, updateGlobalMcpExtension, addWorkspaceMcpExtension, removeWorkspaceMcpExtension, updateWorkspaceMcpExtension, getCatalogItems, buildInstalledConfigFromRecommended, checkMcpHealth, getCachedHealth, cacheHealth, getRecommendedMcpServer, listSkills, installSkill, removeSkill, recordSkillInstall, getSkillLedgerEntry, getRecommendedSkillRepos, searchSkillRepos, searchMcpServers, startAuthorization, completeAuthorization, deleteMcpToken, getMcpToken, } from '../../agent/index.js';
17
17
  import { loadUserConfig, saveUserConfig } from '../../core/config/user-config.js';
18
18
  import { ensurePeekabooWarm } from '../../agent/mcp/bridge.js';
19
19
  import { runtime } from '../runtime.js';
@@ -582,6 +582,59 @@ async function fetchOneRepoMeta(source) {
582
582
  return null;
583
583
  }
584
584
  }
585
+ /**
586
+ * Default-branch HEAD commit SHA per source — the signal we diff installed
587
+ * skills against. Cached with a much shorter TTL than the star/pushedAt meta
588
+ * (10 min vs 24h) so "update available" stays close to real-time without
589
+ * hammering the API; we only fetch it for collections that are actually
590
+ * installed. Cache only on success so a transient null never sticks.
591
+ */
592
+ const repoHeadShaCache = new Map();
593
+ const REPO_HEAD_SHA_TTL_MS = 10 * 60 * 1000;
594
+ const repoHeadShaInflight = new Map();
595
+ async function fetchRepoHeadSha(source) {
596
+ const parsed = parseSourceToOwnerRepo(source);
597
+ if (!parsed)
598
+ return null;
599
+ const slug = `${parsed.owner}/${parsed.repo}`;
600
+ const cached = repoHeadShaCache.get(slug);
601
+ if (cached && Date.now() - cached.cachedAt < REPO_HEAD_SHA_TTL_MS)
602
+ return cached.value;
603
+ const inflight = repoHeadShaInflight.get(slug);
604
+ if (inflight)
605
+ return inflight;
606
+ const promise = (async () => {
607
+ try {
608
+ const controller = new AbortController();
609
+ const timer = setTimeout(() => controller.abort(), 5_000);
610
+ const token = await resolveGithubToken();
611
+ // `commits?per_page=1` returns the latest commit on the default branch
612
+ // without needing to know the branch name first.
613
+ const res = await fetch(`https://api.github.com/repos/${slug}/commits?per_page=1`, {
614
+ signal: controller.signal,
615
+ headers: {
616
+ 'Accept': 'application/vnd.github+json',
617
+ 'User-Agent': 'pikiloom-dashboard',
618
+ 'X-GitHub-Api-Version': '2022-11-28',
619
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
620
+ },
621
+ });
622
+ clearTimeout(timer);
623
+ if (!res.ok)
624
+ return null;
625
+ const data = await res.json();
626
+ const sha = Array.isArray(data) && data[0]?.sha ? String(data[0].sha) : null;
627
+ if (sha)
628
+ repoHeadShaCache.set(slug, { value: sha, cachedAt: Date.now() });
629
+ return sha;
630
+ }
631
+ catch {
632
+ return null;
633
+ }
634
+ })().finally(() => repoHeadShaInflight.delete(slug));
635
+ repoHeadShaInflight.set(slug, promise);
636
+ return promise;
637
+ }
585
638
  async function ensureRepoMeta(sources) {
586
639
  const now = Date.now();
587
640
  const stale = sources.filter(s => {
@@ -656,30 +709,56 @@ app.get('/api/extensions/skills/catalog', async (c) => {
656
709
  const installedByName = new Map();
657
710
  for (const s of scopedInstalled)
658
711
  installedByName.set(s.name.toLowerCase(), s);
659
- const items = filtered.map(repo => {
660
- const meta = githubMetaCache.get(repo.source)?.value;
712
+ // Resolve each repo's installed sub-skills first (sync). Prefer the remote
713
+ // listing intersection over the static `repo.skills` hint — most catalog
714
+ // entries don't fill the hint, and the GitHub listing is authoritative.
715
+ const computeInstalledNames = (repo) => {
661
716
  const remote = remoteSkillsCache.get(repo.source)?.value;
662
- const owner = extractGithubOwner(repo.source);
663
- const iconUrl = repo.iconUrl
664
- ?? (owner ? `https://github.com/${owner}.png?size=80` : undefined);
665
- // Prefer the remote-listing intersection over the static `repo.skills` hint
666
- // — most catalog entries don't bother filling the hint, and the GitHub
667
- // listing is authoritative when cached.
668
- let installedSkillNames;
669
717
  if (remote) {
670
- installedSkillNames = remote.skills
718
+ return remote.skills
671
719
  .map(s => s.name)
672
720
  .filter(name => installedByName.has(name.toLowerCase()));
673
721
  }
674
- else {
675
- const hints = (repo.skills || []).map(s => s.toLowerCase());
676
- installedSkillNames = scopedInstalled
677
- .filter(s => hints.includes(s.name.toLowerCase()))
678
- .map(s => s.name);
722
+ const hints = (repo.skills || []).map(s => s.toLowerCase());
723
+ return scopedInstalled
724
+ .filter(s => hints.includes(s.name.toLowerCase()))
725
+ .map(s => s.name);
726
+ };
727
+ const perRepo = filtered.map(repo => ({ repo, installedNames: computeInstalledNames(repo) }));
728
+ // Update detection — only for installed collections (it's the only place the
729
+ // signal is meaningful, and it keeps API calls proportional to what's
730
+ // actually installed). Diff the live remote HEAD against the provenance
731
+ // ledger. For installs predating the ledger (e.g. via skills.sh) we baseline
732
+ // the current HEAD so future commits surface as updates, rather than
733
+ // fabricating one we can't prove.
734
+ const ledgerScope = scope === 'workspace' ? { workdir } : { global: true };
735
+ const updateBySource = new Map();
736
+ await Promise.all(perRepo.map(async ({ repo, installedNames }) => {
737
+ if (installedNames.length === 0)
738
+ return;
739
+ const latestSha = await fetchRepoHeadSha(repo.source).catch(() => null);
740
+ const entry = getSkillLedgerEntry(repo.source, ledgerScope);
741
+ let installedSha = entry?.sha ?? null;
742
+ if (!entry && latestSha) {
743
+ recordSkillInstall(repo.source, { ...ledgerScope, sha: latestSha, names: installedNames });
744
+ installedSha = latestSha;
679
745
  }
680
- const firstMatch = installedSkillNames[0]
681
- ? installedByName.get(installedSkillNames[0].toLowerCase())
746
+ updateBySource.set(repo.source, {
747
+ installedSha,
748
+ latestSha,
749
+ updateAvailable: !!(installedSha && latestSha && installedSha !== latestSha),
750
+ });
751
+ }));
752
+ const items = perRepo.map(({ repo, installedNames }) => {
753
+ const meta = githubMetaCache.get(repo.source)?.value;
754
+ const remote = remoteSkillsCache.get(repo.source)?.value;
755
+ const owner = extractGithubOwner(repo.source);
756
+ const iconUrl = repo.iconUrl
757
+ ?? (owner ? `https://github.com/${owner}.png?size=80` : undefined);
758
+ const firstMatch = installedNames[0]
759
+ ? installedByName.get(installedNames[0].toLowerCase())
682
760
  : undefined;
761
+ const upd = updateBySource.get(repo.source);
683
762
  return {
684
763
  id: repo.id,
685
764
  name: repo.name,
@@ -689,19 +768,27 @@ app.get('/api/extensions/skills/catalog', async (c) => {
689
768
  category: repo.category,
690
769
  recommendedScope: repo.recommendedScope,
691
770
  homepage: repo.homepage,
692
- installed: installedSkillNames.length > 0,
771
+ installed: installedNames.length > 0,
693
772
  scope: firstMatch?.scope,
694
- installedNames: installedSkillNames,
773
+ installedNames,
695
774
  stars: meta?.stars,
696
775
  pushedAt: meta?.pushedAt,
697
776
  iconUrl,
698
777
  totalCount: remote?.skills.length,
699
778
  partial: remote?.partial,
779
+ pinned: repo.pinned,
780
+ updateAvailable: upd?.updateAvailable,
781
+ installedSha: upd?.installedSha,
782
+ latestSha: upd?.latestSha,
700
783
  };
701
784
  });
702
- // Authority = community popularity. Sort by stars desc, with no-data entries
703
- // sinking to the bottom so the most-loved repos surface first.
704
- items.sort((a, b) => (b.stars ?? -1) - (a.stars ?? -1));
785
+ // Pinned first-party packs lead; then community popularity (stars desc, with
786
+ // no-data entries sinking to the bottom so the most-loved repos surface first).
787
+ items.sort((a, b) => {
788
+ if (!!a.pinned !== !!b.pinned)
789
+ return a.pinned ? -1 : 1;
790
+ return (b.stars ?? -1) - (a.stars ?? -1);
791
+ });
705
792
  return c.json({ ok: true, items, installed });
706
793
  });
707
794
  /** POST /api/extensions/skills/install — install a skill via npx skills add. */
@@ -715,13 +802,39 @@ app.post('/api/extensions/skills/install', async (c) => {
715
802
  if (!isGlobal && !isValidWorkdir(workdir)) {
716
803
  return c.json({ ok: false, error: 'valid workdir is required for project-scoped install' }, 400);
717
804
  }
718
- const result = await installSkill(source.trim(), { global: isGlobal, skill, workdir });
805
+ // Resolve the remote HEAD so installSkill can record provenance for update
806
+ // detection. Best-effort — a failed lookup just installs without a baseline.
807
+ const sourceSha = await fetchRepoHeadSha(source.trim()).catch(() => null);
808
+ const result = await installSkill(source.trim(), { global: isGlobal, skill, workdir, sourceSha });
719
809
  return c.json(result);
720
810
  }
721
811
  catch (e) {
722
812
  return c.json({ ok: false, error: e?.message || 'installation failed' }, 500);
723
813
  }
724
814
  });
815
+ /**
816
+ * POST /api/extensions/skills/update — pull the latest version of an installed
817
+ * collection. Re-runs `skills add <source>` (no `-s`, so every skill from the
818
+ * source is refreshed) and advances the provenance ledger to the new HEAD.
819
+ */
820
+ app.post('/api/extensions/skills/update', async (c) => {
821
+ try {
822
+ const body = await c.req.json();
823
+ const { source, global: isGlobal, workdir: reqWorkdir } = body;
824
+ if (!source?.trim())
825
+ return c.json({ ok: false, error: 'source is required' }, 400);
826
+ const workdir = reqWorkdir || runtime.getRequestWorkdir();
827
+ if (!isGlobal && !isValidWorkdir(workdir)) {
828
+ return c.json({ ok: false, error: 'valid workdir is required for project-scoped update' }, 400);
829
+ }
830
+ const sourceSha = await fetchRepoHeadSha(source.trim()).catch(() => null);
831
+ const result = await installSkill(source.trim(), { global: isGlobal, workdir, sourceSha });
832
+ return c.json({ ...result, sha: sourceSha });
833
+ }
834
+ catch (e) {
835
+ return c.json({ ok: false, error: e?.message || 'update failed' }, 500);
836
+ }
837
+ });
725
838
  /** POST /api/extensions/skills/remove — remove an installed skill. */
726
839
  app.post('/api/extensions/skills/remove', async (c) => {
727
840
  try {
@@ -9,7 +9,6 @@ import { serveStatic } from '@hono/node-server/serve-static';
9
9
  import path from 'node:path';
10
10
  import fs from 'node:fs';
11
11
  import { exec } from 'node:child_process';
12
- import { WebSocketServer } from 'ws';
13
12
  import configRoutes from './routes/config.js';
14
13
  import agentRoutes, { preloadAgentStatus } from './routes/agents.js';
15
14
  import sessionRoutes from './routes/sessions.js';
@@ -19,67 +18,15 @@ import modelsRoutes from './routes/models.js';
19
18
  import localModelsRoutes from './routes/local-models.js';
20
19
  import { runtime } from './runtime.js';
21
20
  import { registerProcessRuntime } from '../core/process-control.js';
21
+ import { mountPikichannel } from '../pikichannel/server.js';
22
22
  // ---------------------------------------------------------------------------
23
23
  // Constants
24
24
  // ---------------------------------------------------------------------------
25
25
  const DASHBOARD_PORT_RETRY_LIMIT = 10;
26
- const WS_KEEPALIVE_MS = 25_000;
27
- function attachWebSocketServer(httpServer) {
28
- const wss = new WebSocketServer({ noServer: true });
29
- const clients = new Set();
30
- httpServer.on('upgrade', (req, socket, head) => {
31
- const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
32
- if (url.pathname !== '/ws') {
33
- socket.destroy();
34
- return;
35
- }
36
- wss.handleUpgrade(req, socket, head, (ws) => {
37
- wss.emit('connection', ws, req);
38
- });
39
- });
40
- wss.on('connection', (ws) => {
41
- clients.add(ws);
42
- const keepalive = setInterval(() => {
43
- if (ws.readyState === ws.OPEN)
44
- ws.ping();
45
- }, WS_KEEPALIVE_MS);
46
- ws.on('message', (raw) => {
47
- try {
48
- const msg = JSON.parse(String(raw));
49
- if (msg?.type === 'ping') {
50
- ws.send(JSON.stringify({ type: 'pong' }));
51
- }
52
- }
53
- catch { /* ignore malformed messages */ }
54
- });
55
- ws.on('close', () => {
56
- clients.delete(ws);
57
- clearInterval(keepalive);
58
- });
59
- ws.on('error', () => {
60
- clients.delete(ws);
61
- clearInterval(keepalive);
62
- });
63
- });
64
- const onEvent = (event) => {
65
- const data = JSON.stringify(event);
66
- for (const ws of clients) {
67
- if (ws.readyState === ws.OPEN) {
68
- ws.send(data);
69
- }
70
- }
71
- };
72
- runtime.events.on('dashboard-event', onEvent);
73
- const closeAllClients = () => {
74
- runtime.events.off('dashboard-event', onEvent);
75
- for (const ws of clients)
76
- ws.close();
77
- clients.clear();
78
- wss.close();
79
- };
80
- httpServer.on('close', closeAllClients);
81
- return { closeAllClients };
82
- }
26
+ // The dashboard's live push transport is pikichannel (`/pikichannel/ws`) — the
27
+ // same universal channel mobile/web clients use. There is no separate dashboard
28
+ // WebSocket layer anymore; the pikichannel adapter subscribes to the runtime
29
+ // 'dashboard-event' bus directly, so the SPA and remote clients share one stack.
83
30
  // ---------------------------------------------------------------------------
84
31
  // Server
85
32
  // ---------------------------------------------------------------------------
@@ -103,6 +50,17 @@ export async function startDashboard(opts = {}) {
103
50
  app.route('/', cliRoutes);
104
51
  app.route('/', modelsRoutes);
105
52
  app.route('/', localModelsRoutes);
53
+ // -- pikichannel: pluggable agent-session transport (WebSocket + WebRTC) --
54
+ // Mounted BEFORE the static catch-all so its routes (demo page, browser SDK,
55
+ // status) win. Returns a handle whose upgrade router is attached per HTTP
56
+ // server below. Failure here must not block the dashboard, so it is guarded.
57
+ let pikichannel = null;
58
+ try {
59
+ pikichannel = await mountPikichannel(app);
60
+ }
61
+ catch (err) {
62
+ runtime.warn(`[pikichannel] mount failed: ${err?.message || err}`);
63
+ }
106
64
  // -- Static files: serve dashboard build output --
107
65
  // Resolve path relative to this file's location (src/ or dist/)
108
66
  const dashboardRoot = path.resolve(import.meta.dirname, '..', '..', 'dashboard', 'dist');
@@ -149,7 +107,6 @@ export async function startDashboard(opts = {}) {
149
107
  });
150
108
  // -- Process runtime registration --
151
109
  let nodeServer = null;
152
- let wsHandle = null;
153
110
  const RESTART_CLOSE_TIMEOUT_MS = 3000;
154
111
  const unregisterProcessRuntime = registerProcessRuntime({
155
112
  label: 'dashboard',
@@ -158,9 +115,9 @@ export async function startDashboard(opts = {}) {
158
115
  resolve();
159
116
  return;
160
117
  }
161
- // Close all WebSocket clients first — otherwise server.close() hangs
162
- // waiting for persistent connections to end.
163
- wsHandle?.closeAllClients();
118
+ // Tear down pikichannel peers first — otherwise server.close() hangs
119
+ // waiting for the persistent WebSocket/datachannel connections to end.
120
+ pikichannel?.stop();
164
121
  const timer = setTimeout(resolve, RESTART_CLOSE_TIMEOUT_MS);
165
122
  nodeServer.close(() => { clearTimeout(timer); resolve(); });
166
123
  }),
@@ -181,8 +138,14 @@ export async function startDashboard(opts = {}) {
181
138
  // before Hono's request listener consumes the connection.
182
139
  const requestListener = getRequestListener(app.fetch);
183
140
  const server = http.createServer(requestListener);
184
- // Attach WebSocket BEFORE listening ensures upgrade events are captured
185
- wsHandle = attachWebSocketServer(server);
141
+ // Single WebSocket-upgrade dispatcher (attached before listening so no
142
+ // upgrade is missed): pikichannel owns `/pikichannel/*` — its WS data
143
+ // channel and WebRTC signaling. Anything else is rejected.
144
+ server.on('upgrade', (req, socket, head) => {
145
+ if (pikichannel?.handleUpgrade(req, socket, head))
146
+ return;
147
+ socket.destroy();
148
+ });
186
149
  server.listen(port, () => {
187
150
  if (settled)
188
151
  return;