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.
- package/dashboard/dist/assets/{AgentTab-Crg9GEUv.js → AgentTab-G0uiUjBk.js} +1 -1
- package/dashboard/dist/assets/ConnectionModal-BQvV5ok2.js +1 -0
- package/dashboard/dist/assets/{DirBrowser-Do4XzF2X.js → DirBrowser-BGacUCRb.js} +1 -1
- package/dashboard/dist/assets/ExtensionsTab-Bd2rjOVu.js +1 -0
- package/dashboard/dist/assets/{IMAccessTab-BX90Aiuq.js → IMAccessTab-BD-ZwAOb.js} +1 -1
- package/dashboard/dist/assets/{Modal-Dd5twuoE.js → Modal-BwmINu44.js} +1 -1
- package/dashboard/dist/assets/{Modals--Xl6dsUf.js → Modals-ncL7g7fW.js} +1 -1
- package/dashboard/dist/assets/{Select-Bp6SwWv6.js → Select-D7Iz4BLf.js} +1 -1
- package/dashboard/dist/assets/{SessionPanel-CHaOUor-.js → SessionPanel-zxiA8Pfd.js} +1 -1
- package/dashboard/dist/assets/{SystemTab-MVpMgFFD.js → SystemTab-DZjEZmVB.js} +1 -1
- package/dashboard/dist/assets/{index-7pyJSn4B.js → index-CTHVuMnd.js} +17 -17
- package/dashboard/dist/assets/index-CyCspgtc.css +1 -0
- package/dashboard/dist/assets/index-DEvh1R9o.js +3 -0
- package/dashboard/dist/assets/{shared-CxR9M3rn.js → shared-D5VHAaFg.js} +1 -1
- package/dashboard/dist/index.html +2 -2
- package/dist/agent/index.js +1 -1
- package/dist/agent/skill-installer.js +95 -0
- package/dist/catalog/skill-repos.js +12 -0
- package/dist/cli/main.js +31 -1
- package/dist/core/secrets/ref.js +8 -0
- package/dist/core/secrets/store.js +31 -11
- package/dist/dashboard/routes/extensions.js +137 -24
- package/dist/dashboard/server.js +27 -64
- package/dist/pikichannel/adapter-pikiloom.js +220 -0
- package/dist/pikichannel/code.js +35 -0
- package/dist/pikichannel/codec.js +50 -0
- package/dist/pikichannel/host.js +252 -0
- package/dist/pikichannel/protocol.js +105 -0
- package/dist/pikichannel/rendezvous-broker.js +114 -0
- package/dist/pikichannel/rendezvous-host.js +138 -0
- package/dist/pikichannel/server.js +284 -0
- package/dist/pikichannel/transport.js +49 -0
- package/dist/pikichannel/transports/webrtc-host.js +61 -0
- package/dist/pikichannel/transports/webrtc-shared.js +132 -0
- package/dist/pikichannel/transports/websocket-host.js +78 -0
- package/dist/pikichannel/web/demo.html +246 -0
- package/dist/pikichannel/web/sdk.js +361 -0
- package/package.json +4 -2
- package/dashboard/dist/assets/ExtensionsTab-j77Yqoz1.js +0 -1
- package/dashboard/dist/assets/index-BiPI4uFE.js +0 -3
- 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 = !
|
|
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
|
package/dist/core/secrets/ref.js
CHANGED
|
@@ -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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
660
|
-
|
|
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
|
-
|
|
718
|
+
return remote.skills
|
|
671
719
|
.map(s => s.name)
|
|
672
720
|
.filter(name => installedByName.has(name.toLowerCase()));
|
|
673
721
|
}
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
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
|
-
|
|
681
|
-
|
|
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:
|
|
771
|
+
installed: installedNames.length > 0,
|
|
693
772
|
scope: firstMatch?.scope,
|
|
694
|
-
installedNames
|
|
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
|
-
//
|
|
703
|
-
// sinking to the bottom so the most-loved repos surface first.
|
|
704
|
-
items.sort((a, b) =>
|
|
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
|
-
|
|
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 {
|
package/dist/dashboard/server.js
CHANGED
|
@@ -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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
//
|
|
162
|
-
// waiting for persistent connections to end.
|
|
163
|
-
|
|
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
|
-
//
|
|
185
|
-
|
|
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;
|